Fabric 1.20.1: server features (commands, LuckPerms, Placeholder API, economy API, client link) and client mod (HUD, editor, shop and market windows); Paper client link; docs and examples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 19:38:18 +00:00
1 parent 84c9d46892
commit 2bcdf26351
43 files changed
+2112 -42

No files matched your search

@@ -1,11 +1,22 @@
package net.scopenet.fabric;
import net.fabricmc.api.DedicatedServerModInitializer;
import net.fabricmc.loader.api.FabricLoader;
/** Server-only; lifecycle and admission hooks are shared mixins. */
/** Server-only; lifecycle and admission hooks are shared mixins. Commands and extras need Fabric API (1.20.1 build). */
public final class ScopenetFabric implements DedicatedServerModInitializer {
@Override public void onInitializeServer() {
if (Boolean.getBoolean("scopenet.verifyMixins"))
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
if (!FabricLoader.getInstance().isModLoaded("fabric-api")) return;
try {
Class<?> features = Class.forName("net.scopenet.fabric.features.FabricFeatures");
((Runnable) features.getDeclaredConstructor().newInstance()).run();
} catch (ClassNotFoundException e) {
// This build (another Minecraft version) only has the core integration.
} catch (Throwable t) {
System.err.println("[SCOPENET] Commands and extras could not start: " + t);
t.printStackTrace();
}
}
}
@@ -6,5 +6,6 @@
"environment": "server",
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
}
@@ -0,0 +1,60 @@
package net.scopenet.fabric.api;
import com.google.gson.JsonObject;
import net.scopenet.core.Panel;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
/**
* SCOPENET's economy for other mods (the Fabric stand-in for Vault). Balances live in the panel, so every call is
* asynchronous. Every change carries an operation id, so a retried call never pays twice.
*
* <pre>{@code
* ScopenetEconomy.get().ifPresent(eco -> eco.balance(uuid).thenAccept(b -> ...));
* }</pre>
*/
public final class ScopenetEconomy {
private static final AtomicReference<ScopenetEconomy> INSTANCE = new AtomicReference<>();
private final Panel panel;
private final java.util.concurrent.Executor io;
public ScopenetEconomy(Panel panel, java.util.concurrent.Executor io) { this.panel = panel; this.io = io; }
/** Empty until the server has started and SCOPENET economy is enabled. */
public static java.util.Optional<ScopenetEconomy> get() { return java.util.Optional.ofNullable(INSTANCE.get()); }
public static void install(ScopenetEconomy economy) { INSTANCE.set(economy); }
public static void uninstall() { INSTANCE.set(null); }
public CompletableFuture<Double> balance(UUID player, String name) {
JsonObject body = new JsonObject();
body.addProperty("uuid", player.toString());
body.addProperty("username", name);
return CompletableFuture.supplyAsync(() -> {
try { return panel.call("economy/balance", body).getAsJsonObject().get("balance").getAsDouble(); }
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
}, io);
}
/** Add (positive) or remove (negative) money. Fails, without changing anything, if the player can't afford it. Returns the new balance. */
public CompletableFuture<Double> change(UUID player, String name, double delta, String reason) {
JsonObject body = new JsonObject();
body.addProperty("operation_id", UUID.randomUUID().toString());
body.addProperty("uuid", player.toString());
body.addProperty("username", name);
body.addProperty("delta", delta);
body.addProperty("description", reason == null || reason.isBlank() ? "Other mod" : reason);
return CompletableFuture.supplyAsync(() -> {
try { return panel.call("economy/adjust", body).getAsJsonObject().get("balance").getAsDouble(); }
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
}, io);
}
public CompletableFuture<Double> deposit(UUID player, String name, double amount, String reason) { return change(player, name, Math.abs(amount), reason); }
public CompletableFuture<Double> withdraw(UUID player, String name, double amount, String reason) { return change(player, name, -Math.abs(amount), reason); }
}
@@ -0,0 +1,47 @@
package net.scopenet.fabric.features;
import net.scopenet.core.EssentialsConfig;
import net.scopenet.core.Features;
import net.scopenet.integration.Settings;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/** The Fabric-only keys in config/scopenet.properties (the shared keys are read by {@link Settings}). */
final class FabricConfig {
static final Path FILE = Path.of("config", "scopenet.properties");
private final Properties p = new Properties();
static FabricConfig load() {
FabricConfig c = new FabricConfig();
if (Files.exists(FILE)) {
try (Reader in = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { c.p.load(in); }
catch (IOException e) { System.err.println("[SCOPENET] Could not read " + FILE + ": " + e.getMessage()); }
}
return c;
}
private int integer(String key, int fallback) {
try { return Integer.parseInt(p.getProperty(key, String.valueOf(fallback)).trim()); } catch (NumberFormatException e) { return fallback; }
}
boolean bool(String key, boolean fallback) { return Boolean.parseBoolean(p.getProperty(key, String.valueOf(fallback)).trim()); }
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
EssentialsConfig essentials() {
return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)),
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15);
}
Features features(Settings s) {
return new Features(s.essentialsEnabled(), s.economyEnabled(), s.guildsEnabled(), s.socialEnabled(), s.landClaimingEnabled(),
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
}
/** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */
boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); }
}
@@ -0,0 +1,214 @@
package net.scopenet.fabric.features;
import com.google.gson.JsonArray;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.*;
import net.scopenet.fabric.api.ScopenetEconomy;
import net.scopenet.integration.Integration;
import net.scopenet.integration.Settings;
import net.scopenet.minecraft.Bridge;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
/**
* Everything beyond sign-in on Fabric 1.20.1: the shared commands (homes, warps, TPA, RTP, economy, guilds…), LuckPerms
* permissions, Placeholder API, the economy API for other mods, and the link to the optional client mod.
* Runs when Fabric API is installed; all behaviour comes from {@code net.scopenet.core}.
*/
public final class FabricFeatures implements Runnable {
private static final Logger LOG = Logger.getLogger("SCOPENET");
private static final ResourceLocation TO_CLIENT = new ResourceLocation(Wire.S2C);
private static final ResourceLocation FROM_CLIENT = new ResourceLocation(Wire.C2S);
private MinecraftServer server;
private FabricPlatform platform;
private Env env;
private CommandSet commands;
private PlayerCache cache;
private ClientLink link;
private Perms perms;
private FabricConfig config;
private int ticks;
private String placeholderStatus = "off";
CommandSet commandSet() { return commands; }
@Override public void run() {
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
ServerPlayConnectionEvents.DISCONNECT.register((handler, s) -> { if (link != null) link.disconnected(handler.getPlayer().getUUID()); });
// /back also returns you to where you died.
ServerLivingEntityEvents.ALLOW_DEATH.register((entity, source, amount) -> {
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
return true;
});
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data);
s.execute(() -> { if (link != null) link.receive(platform.wrap(player), data); });
});
}
// ---- lifecycle -------------------------------------------------------------------------
private void started(MinecraftServer s) {
Integration integration = Bridge.integration();
if (integration == null) { LOG.warning("SCOPENET is not connected to a panel; commands are off."); return; }
server = s;
config = FabricConfig.load();
perms = new Perms(config.everyoneByDefault());
platform = new FabricPlatform(s, perms);
Path dir = Path.of("config", "scopenet");
env = new Env(platform, new PanelAdapter(integration), config.features(integration.settings()), dir, System::currentTimeMillis, new Random(), LOG);
commands = new CommandSet(env, config.essentials());
cache = new PlayerCache(env);
link = new ClientLink(env, cache, s.getMotd(), (player, bytes) -> {
FriendlyByteBuf buf = PacketByteBufs.create();
buf.writeBytes(bytes);
ServerPlayer target = s.getPlayerList().getPlayer(player.uuid());
if (target != null && ServerPlayNetworking.canSend(target, TO_CLIENT)) ServerPlayNetworking.send(target, TO_CLIENT, buf);
});
integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
registerCommands(s.getCommands().getDispatcher());
registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
LOG.info("SCOPENET commands ready (" + commands.all().size() + " commands, "
+ (Perms.luckPermsPresent() ? "LuckPerms" : "default permissions") + ").");
}
private void stopping() {
ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false;
if (platform != null) platform.shutdown();
commands = null;
}
private void registerPlaceholders() {
if (!config.bool("placeholders.enabled", true)) { placeholderStatus = "off (config)"; return; }
if (!net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("placeholder-api")) { placeholderStatus = "Placeholder API not installed"; return; }
try {
int n = PlaceholderApiModule.register(new Placeholders(env, cache));
placeholderStatus = "active (" + n + ")";
} catch (Throwable t) {
placeholderStatus = "failed: " + t;
LOG.warning("Placeholder API could not be used: " + t);
}
}
private void tick() {
if (commands == null) return;
ticks++;
if (ticks % 20 == 0) {
cache.tick(p -> link.pushState(p));
link.tick();
}
if (ticks % 40 == 0) commands.tick();
}
// ---- commands --------------------------------------------------------------------------
private void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
List<CoreCommand> all = new ArrayList<>(commands.all());
all.add(new ScopenetCommand(this));
for (CoreCommand command : all) {
List<String> names = new ArrayList<>();
names.add(command.name());
names.addAll(command.aliases());
for (String name : names) {
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal(name)
.executes(ctx -> execute(ctx, command, ""))
.then(Commands.argument("args", StringArgumentType.greedyString())
.suggests((ctx, builder) -> suggest(ctx, command, builder))
.executes(ctx -> execute(ctx, command, StringArgumentType.getString(ctx, "args"))));
dispatcher.register(root);
}
}
}
private int execute(CommandContext<CommandSourceStack> ctx, CoreCommand command, String argText) {
ServerPlayer sp = ctx.getSource().getPlayer();
if (sp == null) { ctx.getSource().sendFailure(Component.literal("Only players can use this command.")); return 0; }
String[] args = argText.isBlank() ? new String[0] : argText.trim().split("\\s+");
CorePlayer player = platform.wrap(sp);
try {
command.run(player, args);
} catch (RuntimeException e) {
LOG.warning("/" + command.name() + " failed: " + e);
player.send(Format.RED + "Something went wrong running that command.");
return 0;
}
return 1;
}
private CompletableFuture<Suggestions> suggest(CommandContext<CommandSourceStack> ctx, CoreCommand command, SuggestionsBuilder builder) {
ServerPlayer sp = ctx.getSource().getPlayer();
if (sp != null) {
String before = builder.getInput().substring(0, builder.getStart()).stripLeading();
if (before.startsWith("/")) before = before.substring(1);
String[] tokens = before.isBlank() ? new String[0] : before.split(" ");
List<String> typed = new ArrayList<>(Arrays.asList(tokens).subList(Math.min(1, tokens.length), tokens.length));
typed.add(builder.getRemaining());
String partial = builder.getRemaining().toLowerCase(Locale.ROOT);
for (String candidate : command.complete(platform.wrap(sp), typed.toArray(new String[0]))) {
if (candidate.toLowerCase(Locale.ROOT).startsWith(partial)) builder.suggest(candidate);
}
}
return builder.buildFuture();
}
// ---- /scopenet -------------------------------------------------------------------------
void sendStatus(CorePlayer p) {
Integration integration = Bridge.integration();
Settings s = integration.settings();
p.send(Format.GOLD + "=== SCOPENET Status ===");
p.send(Format.GRAY + "Panel: " + Format.WHITE + s.panel() + Format.GRAY + " Token: " + (s.token().isEmpty() ? Format.RED + "missing" : Format.GREEN + "set"));
Features f = env.features;
p.send(Format.GRAY + "Essentials: " + flag(f.essentials()) + Format.GRAY + " Economy: " + flag(f.economy()) + Format.GRAY + " Guilds: " + flag(f.guilds())
+ Format.GRAY + " Claims: " + flag(f.landClaiming()) + Format.GRAY + " Client link: " + flag(f.clientLink()));
p.send(Format.GRAY + "Claim index: " + (integration.client().claims().loaded() ? Format.GREEN + integration.client().claims().claimCount() + " claims" : Format.YELLOW + "loading"));
p.send(Format.GRAY + "Permissions: " + Format.WHITE + (Perms.luckPermsPresent() ? "LuckPerms" : "defaults (" + (config.everyoneByDefault() ? "everyone" : "operators") + ")"));
p.send(Format.GRAY + "Placeholder API: " + Format.WHITE + placeholderStatus);
p.send(Format.GRAY + "Pending transactions: " + Format.WHITE + commands.jobs.pending());
}
private static String flag(boolean on) { return on ? Format.GREEN + "on" : Format.RED + "off"; }
void reload(CorePlayer p) {
try {
Settings next = Settings.load(FabricConfig.FILE);
Bridge.integration().reload(next);
config = FabricConfig.load();
perms = new Perms(config.everyoneByDefault());
env.features = config.features(next);
commands.essentials.configure(config.essentials());
p.send(Format.GREEN + "SCOPENET config reloaded.");
} catch (Exception e) {
p.send(Format.RED + "Reload failed: " + e.getMessage());
}
}
}
@@ -0,0 +1,34 @@
package net.scopenet.fabric.features;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.TagParser;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.scopenet.core.Item;
/** ItemStack <-> the shared {@link Item}. The exact stack (enchantments, names, NBT) travels as SNBT in {@code data}. */
final class FabricItems {
private FabricItems() {}
static Item toCore(ItemStack stack) {
String registryId = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
return new Item(Item.panelId(registryId), stack.getHoverName().getString(), stack.getCount(), stack.save(new CompoundTag()).toString());
}
/** The stack for an item, restoring the exact NBT when it came from this platform; otherwise rebuilt from id and count. */
static ItemStack fromCore(Item item) {
if (!item.data().isEmpty()) {
try {
ItemStack stack = ItemStack.of(TagParser.parseTag(item.data()));
if (!stack.isEmpty()) return stack;
} catch (Exception ignored) {
// Another platform's serialisation: fall through to id and count.
}
}
ResourceLocation id = ResourceLocation.tryParse(Item.registryId(item.id()));
net.minecraft.world.item.Item type = id == null ? null : BuiltInRegistries.ITEM.get(id);
if (type == null || type == net.minecraft.world.item.Items.AIR) return ItemStack.EMPTY;
return new ItemStack(type, Math.max(1, item.count()));
}
}
@@ -0,0 +1,75 @@
package net.scopenet.fabric.features;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Platform;
import net.scopenet.core.Pos;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** The shared logic's view of a running Fabric server. */
final class FabricPlatform implements Platform {
private final MinecraftServer server;
private final Perms perms;
private final ExecutorService io = Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r, "scopenet-io");
t.setDaemon(true);
return t;
});
FabricPlatform(MinecraftServer server, Perms perms) { this.server = server; this.perms = perms; }
void shutdown() { io.shutdownNow(); }
CorePlayer wrap(ServerPlayer p) { return new FabricPlayer(p, this, perms); }
ServerLevel level(String id) {
ResourceLocation location = ResourceLocation.tryParse(id);
return location == null ? null : server.getLevel(ResourceKey.create(Registries.DIMENSION, location));
}
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(server.getPlayerList().getPlayer(uuid)).map(this::wrap); }
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(server.getPlayerList().getPlayerByName(name)).map(this::wrap); }
@Override public Collection<? extends CorePlayer> online() {
List<CorePlayer> out = new ArrayList<>();
for (ServerPlayer p : server.getPlayerList().getPlayers()) out.add(wrap(p));
return out;
}
@Override public Optional<Pos> safeSurface(String world, int x, int z) {
ServerLevel level = level(world);
if (level == null || level.dimensionType().hasCeiling()) return Optional.empty(); // the Nether roof is bedrock
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
if (y <= level.getMinBuildHeight() + 1) return Optional.empty();
BlockState ground = level.getBlockState(new BlockPos(x, y - 1, z));
if (!ground.blocksMotion() || !ground.getFluidState().isEmpty()) return Optional.empty();
return Optional.of(new Pos(world, x + 0.5, y, z + 0.5, 0, 0));
}
@Override public Pos worldSpawn(String world) {
ServerLevel level = level(world);
if (level == null) level = server.overworld();
BlockPos spawn = level.getSharedSpawnPos();
return new Pos(level.dimension().location().toString(), spawn.getX(), spawn.getY(), spawn.getZ(), 0, 0);
}
@Override public void runMain(Runnable task) { server.execute(task); }
@Override public void runAsync(Runnable task) { io.execute(task); }
}
@@ -0,0 +1,63 @@
package net.scopenet.fabric.features;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Item;
import net.scopenet.core.Pos;
import java.util.Optional;
import java.util.UUID;
/** A {@link CorePlayer} over a Fabric ServerPlayer. */
final class FabricPlayer implements CorePlayer {
private final ServerPlayer player;
private final FabricPlatform platform;
private final Perms perms;
FabricPlayer(ServerPlayer player, FabricPlatform platform, Perms perms) {
this.player = player; this.platform = platform; this.perms = perms;
}
ServerPlayer handle() { return player; }
@Override public UUID uuid() { return player.getUUID(); }
@Override public String name() { return player.getGameProfile().getName(); }
@Override public Pos pos() {
ServerLevel level = player.serverLevel();
return new Pos(level.dimension().location().toString(), player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
}
@Override public void send(String message) { player.sendSystemMessage(Component.literal(message)); }
@Override public void teleport(Pos destination) {
ServerLevel level = platform.level(destination.world());
if (level == null) { send("§cThat dimension is not loaded."); return; }
player.teleportTo(level, destination.x(), destination.y(), destination.z(), destination.yaw(), destination.pitch());
}
@Override public boolean hasPermission(String node) { return perms.has(player, node); }
@Override public long playtimeSeconds() {
return player.getStats().getValue(Stats.CUSTOM.get(Stats.PLAY_TIME)) / 20L;
}
@Override public Optional<Item> heldItem() {
ItemStack stack = player.getMainHandItem();
return stack.isEmpty() ? Optional.empty() : Optional.of(FabricItems.toCore(stack));
}
@Override public void clearHeld() { player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); }
@Override public void give(Item item) {
ItemStack stack = FabricItems.fromCore(item);
if (stack.isEmpty()) { send("§cCould not deliver " + item.id() + " x" + item.count() + ". Ask an admin."); return; }
player.getInventory().add(stack);
if (!stack.isEmpty()) player.drop(stack, false);
}
}
@@ -0,0 +1,21 @@
package net.scopenet.fabric.features;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User;
import net.luckperms.api.util.Tristate;
import net.minecraft.server.level.ServerPlayer;
/** Only loaded when the LuckPerms mod is present (see {@link Perms}). */
final class LuckPermsLookup {
private LuckPermsLookup() {}
/** True or false when LuckPerms has the node set for this player, null when it has no opinion. */
static Boolean check(ServerPlayer player, String node) {
LuckPerms api = LuckPermsProvider.get();
User user = api.getUserManager().getUser(player.getUUID());
if (user == null) return null;
Tristate result = user.getCachedData().getPermissionData().checkPermission(node);
return result == Tristate.UNDEFINED ? null : result.asBoolean();
}
}
@@ -0,0 +1,36 @@
package net.scopenet.fabric.features;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.server.level.ServerPlayer;
/**
* Permission checks. With the LuckPerms mod installed its answer wins whenever the node is set; otherwise (or when
* LuckPerms has no opinion) the defaults apply: ordinary commands follow {@code permissions.default_level}, and
* admin nodes need operator level 2. Same node names as the Paper plugin, so one LuckPerms setup serves both.
*/
final class Perms {
private static final boolean LUCKPERMS = FabricLoader.getInstance().isModLoaded("luckperms");
private final boolean everyoneByDefault;
Perms(boolean everyoneByDefault) { this.everyoneByDefault = everyoneByDefault; }
static boolean luckPermsPresent() { return LUCKPERMS; }
static boolean isAdminNode(String node) {
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
}
boolean has(ServerPlayer player, String node) {
if (LUCKPERMS) {
try {
Boolean answer = LuckPermsLookup.check(player, node);
if (answer != null) return answer;
} catch (Throwable t) {
// LuckPerms not ready yet or an API change: fall back to the defaults below.
}
}
if (isAdminNode(node)) return player.hasPermissions(2);
return everyoneByDefault || player.hasPermissions(2);
}
}
@@ -0,0 +1,43 @@
package net.scopenet.fabric.features;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.Placeholders;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Optional;
/**
* Registers %scopenet:level%, %scopenet:balance%… with Placeholder API (Fabric) when it is installed. Done through
* reflection so SCOPENET builds and runs without it and tolerates small API differences; any problem just leaves
* the placeholders off and is reported in /scopenet status.
*/
final class PlaceholderApiModule {
private PlaceholderApiModule() {}
/** Returns how many placeholders were registered; throws if the API isn't usable. */
static int register(Placeholders values) throws ReflectiveOperationException {
Class<?> placeholders = Class.forName("eu.pb4.placeholders.api.Placeholders");
Class<?> handlerType = Class.forName("eu.pb4.placeholders.api.PlaceholderHandler");
Class<?> resultType = Class.forName("eu.pb4.placeholders.api.PlaceholderResult");
Method register = placeholders.getMethod("register", ResourceLocation.class, handlerType);
Method value = resultType.getMethod("value", String.class);
Method invalid = resultType.getMethod("invalid", String.class);
int count = 0;
for (String key : Placeholders.KEYS) {
Object handler = Proxy.newProxyInstance(handlerType.getClassLoader(), new Class<?>[]{handlerType}, (proxy, method, args) -> {
if (method.getDeclaringClass() == Object.class) return method.invoke(new Object(), args);
Object context = args[0];
ServerPlayer player = (ServerPlayer) context.getClass().getMethod("player").invoke(context);
if (player == null) return invalid.invoke(null, "No player");
Optional<String> text = values.resolve(player.getUUID(), key);
return text.isPresent() ? value.invoke(null, text.get()) : invalid.invoke(null, "Unknown placeholder");
});
register.invoke(null, new ResourceLocation("scopenet", key), handler);
count++;
}
return count;
}
}
@@ -0,0 +1,55 @@
package net.scopenet.fabric.features;
import net.scopenet.core.CommandSet;
import net.scopenet.core.CoreCommand;
import net.scopenet.core.CorePlayer;
import net.scopenet.core.Format;
import java.util.List;
import java.util.Locale;
/** /scopenet help | status | reload (alias /sn), with the same nodes as the Paper plugin. */
final class ScopenetCommand implements CoreCommand {
private final FabricFeatures features;
ScopenetCommand(FabricFeatures features) { this.features = features; }
@Override public String name() { return "scopenet"; }
@Override public List<String> aliases() { return List.of("sn"); }
@Override public String permission() { return "scopenet.command.scopenet"; }
@Override public List<String> complete(CorePlayer player, String[] args) {
return args.length <= 1 ? List.of("help", "status", "reload") : List.of();
}
@Override public void run(CorePlayer p, String[] args) {
String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT);
switch (sub) {
case "help" -> {
if (!p.hasPermission("scopenet.command.scopenet.help")) { p.send(Format.RED + "You do not have permission to view SCOPENET help."); return; }
help(p);
}
case "status" -> {
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
features.sendStatus(p);
}
case "reload" -> {
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
features.reload(p);
}
default -> p.send(Format.RED + "Unknown command. Use " + Format.YELLOW + "/scopenet help");
}
}
private void help(CorePlayer p) {
p.send(Format.GOLD + "=================== " + Format.YELLOW + "SCOPENET Commands" + Format.GOLD + " ===================");
CommandSet set = features.commandSet();
if (set == null) return;
for (CoreCommand c : set.all()) {
if (!p.hasPermission(c.permission())) continue;
String aliases = c.aliases().isEmpty() ? "" : Format.DARK_GRAY + " (" + String.join(", ", c.aliases().stream().map(a -> "/" + a).toList()) + ")";
p.send(Format.YELLOW + " /" + c.name() + aliases);
}
p.send(Format.GRAY + "Open the launcher's Command guide for what each one does.");
}
}