Add SCOPENET developer API and plugin compatibility layer (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark)

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 18:14:50 +00:00
1 parent c4a222451c
commit 9f3ed3fe24
38 files changed
+2185 -4

No files matched your search

+11 -1
View File
@@ -1,8 +1,17 @@
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
repositories {
maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' }
maven { url = 'https://jitpack.io' }
}
dependencies {
implementation project(':common')
implementation project(':api')
implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
// Optional integrations: the plugin detects these at runtime and works without them.
compileOnly 'net.luckperms:api:5.4'
compileOnly 'me.clip:placeholderapi:2.11.6'
compileOnly('com.github.MilkBowl:VaultAPI:1.7.1') { exclude group: 'org.bukkit' }
}
processResources {
inputs.property 'version', project.version
@@ -12,6 +21,7 @@ jar {
archiveBaseName = 'scopenet-paper'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
}
@@ -1,6 +1,11 @@
package net.scopenet.paper;
import net.scopenet.api.ScopenetApi;
import net.scopenet.api.ScopenetApiProvider;
import net.scopenet.integration.*;
import net.scopenet.paper.compat.CompatManager;
import net.scopenet.paper.compat.EventBridge;
import net.scopenet.paper.compat.ScopenetApiImpl;
import net.scopenet.paper.commands.*;
import org.bukkit.*;
import org.bukkit.entity.Player;
@@ -17,6 +22,8 @@ import java.util.UUID;
public final class ScopenetPlugin extends JavaPlugin implements Listener {
private Integration integration;
private EconomyHandler economy;
private ScopenetApiImpl api;
private CompatManager compat;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -46,8 +53,21 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
}
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
// and receive level-ups, achievements and guild changes as ordinary Bukkit events.
api = new ScopenetApiImpl(integration.client());
ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
integration.onNotifications(new EventBridge(this, api, getLogger())::accept);
Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
// Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
// switched on by the compat manager once every plugin has loaded.
compat = new CompatManager(this, integration, api);
Bukkit.getScheduler().runTask(this, compat::start);
// Register /scopenet admin & help command
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration);
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
bindCommand("scopenet", scopenetCmd);
// Register Essentials commands & GUI
@@ -152,7 +172,19 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
}
}
/** Keep the API's cached profiles of online players fresh, for placeholders and getCachedPlayer(). */
private void warmProfiles() {
if (api == null) return;
for (Player p : Bukkit.getOnlinePlayers()) api.peek(p.getUniqueId());
}
@Override public void onDisable() {
if (compat != null) compat.stop();
if (api != null) {
getServer().getServicesManager().unregister(ScopenetApi.class, api);
ScopenetApiProvider.unregister(api);
api.close();
}
if (integration != null) integration.close();
}
@@ -180,6 +212,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void leave(PlayerQuitEvent event) {
deniedAt.remove(event.getPlayer().getUniqueId());
if (api != null) api.forget(event.getPlayer().getUniqueId());
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
}
@@ -12,9 +12,12 @@ public final class ScopenetCommandHandler implements CommandExecutor {
private final ScopenetPlugin plugin;
private final Integration integration;
public ScopenetCommandHandler(ScopenetPlugin plugin, Integration integration) {
private final java.util.function.Supplier<net.scopenet.paper.compat.CompatManager> compat;
public ScopenetCommandHandler(ScopenetPlugin plugin, Integration integration, java.util.function.Supplier<net.scopenet.paper.compat.CompatManager> compat) {
this.plugin = plugin;
this.integration = integration;
this.compat = compat;
}
@Override
@@ -116,6 +119,22 @@ public final class ScopenetCommandHandler implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "Social: " + formatFlag(s.socialEnabled()));
sender.sendMessage(ChatColor.GRAY + "Essentials: " + formatFlag(s.essentialsEnabled()));
sender.sendMessage(ChatColor.GRAY + "Economy: " + formatFlag(s.economyEnabled()));
net.scopenet.paper.compat.CompatManager manager = compat.get();
if (manager != null) {
sender.sendMessage(ChatColor.GOLD + "=== Plugin Integrations ===");
for (net.scopenet.paper.compat.CompatManager.Status status : manager.statuses()) {
String mark = switch (status.state()) {
case ACTIVE -> ChatColor.GREEN + "ACTIVE";
case FAILED -> ChatColor.RED + "FAILED";
case DISABLED_IN_CONFIG -> ChatColor.YELLOW + "OFF (config)";
case NOT_INSTALLED -> ChatColor.DARK_GRAY + "not installed";
};
String detail = status.state() == net.scopenet.paper.compat.CompatManager.State.FAILED
? ChatColor.GRAY + " " + status.message() : "";
String version = status.version() == null || status.version().isEmpty() ? "" : ChatColor.DARK_GRAY + " v" + status.version();
sender.sendMessage(ChatColor.GRAY + status.name() + ": " + mark + version + detail);
}
}
}
private String formatFlag(boolean enabled) {
@@ -0,0 +1,77 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonObject;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import java.nio.file.Path;
import java.util.logging.Logger;
/** What an {@link IntegrationModule} gets to work with. */
public final class CompatContext {
private final JavaPlugin plugin;
private final Integration integration;
private final ScopenetApiImpl api;
CompatContext(JavaPlugin plugin, Integration integration, ScopenetApiImpl api) {
this.plugin = plugin;
this.integration = integration;
this.api = api;
}
public JavaPlugin plugin() { return plugin; }
public Integration integration() { return integration; }
public ScopenetApiImpl api() { return api; }
public Logger log() { return plugin.getLogger(); }
public FileConfiguration config() { return plugin.getConfig(); }
public Path dataFolder() { return plugin.getDataFolder().toPath(); }
public boolean getBoolean(String module, String key, boolean fallback) {
return config().getBoolean("integrations." + module + "." + key, fallback);
}
public int getInt(String module, String key, int fallback) {
return config().getInt("integrations." + module + "." + key, fallback);
}
public String getString(String module, String key, String fallback) {
return config().getString("integrations." + module + "." + key, fallback);
}
/** Run later on the server thread. */
public void sync(Runnable task) {
if (plugin.isEnabled()) Bukkit.getScheduler().runTask(plugin, task);
}
/** Run on a worker thread. */
public void async(Runnable task) {
if (plugin.isEnabled()) Bukkit.getScheduler().runTaskAsynchronously(plugin, task);
}
/** Repeat on the server thread. Returns the task id to pass to {@link #cancel}. */
public int everySync(long delayTicks, long periodTicks, Runnable task) {
return Bukkit.getScheduler().runTaskTimer(plugin, task, delayTicks, periodTicks).getTaskId();
}
/** Repeat on a worker thread. */
public int everyAsync(long delayTicks, long periodTicks, Runnable task) {
return Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, task, delayTicks, periodTicks).getTaskId();
}
public void cancel(int taskId) {
Bukkit.getScheduler().cancelTask(taskId);
}
/**
* Send a report about an integration to the panel (call from a worker thread). Returns the
* panel's answer, whose {@code config} member carries settings for that integration.
*/
public JsonObject report(String name, String version, JsonObject data) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("name", name);
body.addProperty("version", version == null ? "" : version);
body.add("data", data);
return integration.client().post("integrations/report", body);
}
}
@@ -0,0 +1,165 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* Finds the plugins SCOPENET can work with and switches each integration on or
* off as they come and go, so nothing is hard-wired into {@code ScopenetPlugin}.
* Every integration can also be turned off with {@code integrations.<id>.enabled: false}.
*
* Integrations are only instantiated once their plugin is present: their classes refer to that
* plugin's API, which must not be loaded on servers that don't have it.
*/
public final class CompatManager implements Listener {
public enum State { ACTIVE, NOT_INSTALLED, DISABLED_IN_CONFIG, FAILED }
public record Status(String id, String name, String plugin, State state, String version, String message, Map<String, Object> details) {}
/** What we know about an integration before (and without) loading its code. */
private record Candidate(String id, String name, String plugin, Supplier<IntegrationModule> factory) {}
private final JavaPlugin plugin;
private final CompatContext context;
private final List<Candidate> candidates = new ArrayList<>();
private final Map<String, IntegrationModule> running = new ConcurrentHashMap<>();
private final Map<String, String> failures = new ConcurrentHashMap<>();
private int reportTask = -1;
public CompatManager(JavaPlugin plugin, Integration integration, ScopenetApiImpl api) {
this.plugin = plugin;
this.context = new CompatContext(plugin, integration, api);
add("luckperms", "LuckPerms", "LuckPerms", LuckPermsModule::new);
add("placeholderapi", "PlaceholderAPI", "PlaceholderAPI", PlaceholderApiModule::new);
add("vault", "Vault", "Vault", VaultModule::new);
add("coreprotect", "CoreProtect", "CoreProtect", CoreProtectModule::new);
add("worldguard", "WorldGuard", "WorldGuard", WorldGuardModule::new);
add("spark", "Spark", "spark", SparkModule::new);
}
private void add(String id, String name, String pluginName, Supplier<IntegrationModule> factory) {
candidates.add(new Candidate(id, name, pluginName, factory));
}
/** Register listeners and enable whatever is installed. Call on the server thread once all plugins have loaded. */
public void start() {
Bukkit.getPluginManager().registerEvents(this, plugin);
for (Candidate candidate : candidates) tryEnable(candidate);
// Tell the panel which integrations are live, now and every few minutes.
reportTask = context.everyAsync(100, 20L * 60 * 5, this::reportStatus);
}
public void stop() {
if (reportTask >= 0) context.cancel(reportTask);
for (Candidate candidate : candidates) disable(candidate);
}
private Plugin installed(Candidate candidate) {
Plugin other = Bukkit.getPluginManager().getPlugin(candidate.plugin());
return other != null && other.isEnabled() ? other : null;
}
private boolean allowed(Candidate candidate) {
return context.getBoolean(candidate.id(), "enabled", true);
}
private void tryEnable(Candidate candidate) {
if (running.containsKey(candidate.id()) || !allowed(candidate) || installed(candidate) == null) return;
IntegrationModule module = null;
try {
module = candidate.factory().get();
module.enable(context);
running.put(candidate.id(), module);
failures.remove(candidate.id());
context.log().info("Integration enabled: " + candidate.name());
} catch (Throwable t) {
// A broken or incompatible plugin version must never take SCOPENET down with it.
String why = t.getClass().getSimpleName() + (t.getMessage() != null ? ": " + t.getMessage() : "");
failures.put(candidate.id(), why);
context.log().warning("Could not enable the " + candidate.name() + " integration: " + why);
if (module != null) {
try { module.disable(); } catch (Throwable ignored) { /* partially started */ }
}
}
}
private void disable(Candidate candidate) {
IntegrationModule module = running.remove(candidate.id());
if (module == null) return;
try { module.disable(); }
catch (Throwable t) { context.log().warning("Error stopping the " + candidate.name() + " integration: " + t.getMessage()); }
context.log().info("Integration disabled: " + candidate.name());
}
@EventHandler(priority = EventPriority.MONITOR)
public void pluginEnabled(PluginEnableEvent event) {
for (Candidate candidate : candidates) {
if (candidate.plugin().equalsIgnoreCase(event.getPlugin().getName())) Bukkit.getScheduler().runTask(plugin, () -> tryEnable(candidate));
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void pluginDisabled(PluginDisableEvent event) {
for (Candidate candidate : candidates) {
if (candidate.plugin().equalsIgnoreCase(event.getPlugin().getName())) disable(candidate);
}
}
/** Current state of every integration, for /scopenet status and the panel. */
public List<Status> statuses() {
List<Status> out = new ArrayList<>();
for (Candidate c : candidates) {
Plugin other = Bukkit.getPluginManager().getPlugin(c.plugin());
String version = other == null ? "" : other.getDescription().getVersion();
IntegrationModule module = running.get(c.id());
State state;
String message = "";
if (!allowed(c)) { state = State.DISABLED_IN_CONFIG; message = "Turned off in config.yml"; }
else if (failures.containsKey(c.id())) { state = State.FAILED; message = failures.get(c.id()); }
else if (module != null) state = State.ACTIVE;
else { state = State.NOT_INSTALLED; message = c.plugin() + " is not installed"; }
out.add(new Status(c.id(), c.name(), c.plugin(), state, version, message, module == null ? Map.of() : module.details()));
}
return out;
}
private void reportStatus() {
try {
JsonObject data = new JsonObject();
JsonArray list = new JsonArray();
for (Status s : statuses()) {
JsonObject o = new JsonObject();
o.addProperty("id", s.id());
o.addProperty("name", s.name());
o.addProperty("plugin", s.plugin());
o.addProperty("state", s.state().name().toLowerCase(Locale.ROOT));
o.addProperty("version", s.version());
o.addProperty("message", s.message());
JsonObject details = new JsonObject();
s.details().forEach((k, v) -> {
if (v instanceof Number n) details.addProperty(k, n);
else if (v instanceof Boolean b) details.addProperty(k, b);
else details.addProperty(k, String.valueOf(v));
});
o.add("details", details);
list.add(o);
}
data.add("modules", list);
context.report("compat", plugin.getDescription().getVersion(), data);
} catch (Exception e) {
context.log().fine("Could not report integrations to the panel: " + e.getMessage());
}
}
}
@@ -0,0 +1,148 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;
import java.io.File;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
/**
* Reports what CoreProtect is recording (actions per minute, the busiest players) and the
* state of the server's world backups to the panel, so admins can see what's happening
* in-game and whether backups are current without opening the console.
* CoreProtect is used through reflection, so a CoreProtect update can't break SCOPENET.
*/
public final class CoreProtectModule implements IntegrationModule {
private final Map<String, LongAdder> byActor = new ConcurrentHashMap<>();
private final LongAdder total = new LongAdder();
private final long[] minuteKeys = new long[60];
private final long[] minuteCounts = new long[60];
private final long startedAt = System.currentTimeMillis();
private CompatContext context;
private Plugin coreProtect;
private Listener listener;
private int task = -1;
private volatile boolean tracking;
@Override public String id() { return "coreprotect"; }
@Override public String displayName() { return "CoreProtect"; }
@Override public String pluginName() { return "CoreProtect"; }
@Override @SuppressWarnings("unchecked")
public void enable(CompatContext context) {
this.context = context;
this.coreProtect = Bukkit.getPluginManager().getPlugin("CoreProtect");
try {
// CoreProtect 21.3+ fires an event for every action it logs.
Class<? extends Event> type = (Class<? extends Event>) Reflect.load("net.coreprotect.event.CoreProtectPreLogEvent", coreProtect.getClass().getClassLoader());
listener = new Listener() {};
Bukkit.getPluginManager().registerEvent(type, listener, EventPriority.MONITOR, (l, event) -> record(event), context.plugin(), true);
tracking = true;
} catch (ClassNotFoundException | LinkageError e) {
context.log().info("This CoreProtect version has no logging event; reporting its status and your backups only.");
}
task = context.everyAsync(200, 20L * 60, this::report);
}
@Override public void disable() {
if (context != null && task >= 0) context.cancel(task);
task = -1;
if (listener != null) org.bukkit.event.HandlerList.unregisterAll(listener);
listener = null;
}
@Override public Map<String, Object> details() {
return Map.of("logged_this_session", total.sum(), "tracking_actions", tracking);
}
private void record(Event event) {
Object user = Reflect.tryCall(event, "getUser");
byActor.computeIfAbsent(user == null ? "?" : String.valueOf(user), k -> new LongAdder()).increment();
total.increment();
long minute = System.currentTimeMillis() / 60_000;
int slot = (int) (minute % 60);
synchronized (minuteKeys) {
if (minuteKeys[slot] != minute) {
minuteKeys[slot] = minute;
minuteCounts[slot] = 0;
}
minuteCounts[slot]++;
}
}
private void report() {
try {
JsonObject data = new JsonObject();
Object api = Reflect.tryCall(coreProtect, "getAPI");
Object apiVersion = Reflect.tryCall(api, "APIVersion");
if (apiVersion != null) data.addProperty("api_version", String.valueOf(apiVersion));
data.addProperty("tracking_actions", tracking);
data.addProperty("logged_total", total.sum());
data.addProperty("since", startedAt);
long now = System.currentTimeMillis() / 60_000;
JsonArray perMinute = new JsonArray();
long lastHour = 0;
synchronized (minuteKeys) {
for (long m = now - 59; m <= now; m++) {
int slot = (int) (m % 60);
long count = minuteKeys[slot] == m ? minuteCounts[slot] : 0;
perMinute.add(count);
lastHour += count;
}
}
data.add("per_minute", perMinute);
data.addProperty("logged_last_hour", lastHour);
JsonArray top = new JsonArray();
byActor.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue().sum(), a.getValue().sum())).limit(8).forEach(e -> {
JsonObject o = new JsonObject();
o.addProperty("name", e.getKey());
o.addProperty("count", e.getValue().sum());
top.add(o);
});
data.add("top_actors", top);
data.add("backups", backups());
context.report("coreprotect", coreProtect.getDescription().getVersion(), data);
} catch (Exception e) {
context.log().fine("CoreProtect report failed: " + e.getMessage());
}
}
/** Looks for backup archives in {@code integrations.coreprotect.backup-folders} (default: ./backups). */
private JsonObject backups() {
JsonObject out = new JsonObject();
List<String> folders = context.config().getStringList("integrations.coreprotect.backup-folders");
if (folders.isEmpty()) folders = List.of("backups");
long newest = 0, size = 0;
int count = 0;
String newestName = null;
for (String folder : folders) {
File[] files = new File(folder).listFiles(f -> f.isFile() || f.isDirectory());
if (files == null) continue;
for (File f : files) {
if (count >= 2000) break;
count++;
size += f.isFile() ? f.length() : 0;
if (f.lastModified() > newest) {
newest = f.lastModified();
newestName = f.getName();
}
}
}
out.addProperty("found", count > 0);
out.addProperty("count", count);
out.addProperty("bytes", size);
if (newestName != null) {
out.addProperty("latest_name", newestName);
out.addProperty("latest_at", newest);
}
return out;
}
}
@@ -0,0 +1,62 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.api.XpScope;
import net.scopenet.api.event.*;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.UUID;
import java.util.logging.Logger;
/** Turns the events the panel queued for this server into Bukkit events, on the server thread. */
public final class EventBridge {
private final JavaPlugin plugin;
private final ScopenetApiImpl api;
private final Logger log;
public EventBridge(JavaPlugin plugin, ScopenetApiImpl api, Logger log) {
this.plugin = plugin;
this.api = api;
this.log = log;
}
/** Called on the sync worker thread. */
public void accept(JsonArray notifications) {
for (JsonElement element : notifications) {
try {
JsonObject n = element.getAsJsonObject();
UUID uuid = UUID.fromString(n.get("uuid").getAsString());
Event event = toEvent(n.get("kind").getAsString(), uuid, n.has("data") && n.get("data").isJsonObject() ? n.getAsJsonObject("data") : new JsonObject());
api.invalidate(uuid);
if (event == null) continue;
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getPluginManager().callEvent(event));
} catch (RuntimeException e) {
log.warning("Ignoring a malformed SCOPENET event: " + e.getMessage());
}
}
}
static Event toEvent(String kind, UUID uuid, JsonObject d) {
return switch (kind) {
case "level_up" -> new ScopenetLevelUpEvent(uuid, "server".equals(text(d, "scope")) ? XpScope.SERVER : XpScope.GLOBAL,
(int) number(d, "level"), (int) number(d, "previous"), number(d, "xp"));
case "achievement" -> new AchievementUnlockEvent(uuid, text(d, "id"), text(d, "title"), number(d, "xp"));
case "guild_join" -> new GuildJoinEvent(uuid, text(d, "guild_id"), text(d, "guild"), text(d, "tag"), text(d, "role"));
case "guild_leave" -> new GuildLeaveEvent(uuid, text(d, "guild_id"), text(d, "guild"), text(d, "tag"));
default -> null;
};
}
private static String text(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsString() : null;
}
private static long number(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : 0;
}
}
@@ -0,0 +1,30 @@
package net.scopenet.paper.compat;
import java.util.Map;
/**
* One optional connection to another plugin. The {@link CompatManager} finds out
* which plugins are installed and switches the matching modules on and off, so
* {@code ScopenetPlugin} never has to know about any of them.
*/
public interface IntegrationModule {
/** Short id used in config ({@code integrations.<id>.enabled}) and in the panel. */
String id();
/** Human name, e.g. "LuckPerms". */
String displayName();
/** The plugin this integrates with, exactly as it calls itself in plugin.yml. */
String pluginName();
/** Start working. Called on the server thread once the other plugin is enabled. */
void enable(CompatContext context) throws Exception;
/** Stop and undo everything {@link #enable} did. Must be safe to call twice. */
void disable();
/** Extra facts for /scopenet status and the panel, e.g. how many placeholders were registered. */
default Map<String, Object> details() {
return Map.of();
}
}
@@ -0,0 +1,130 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.cacheddata.CachedMetaData;
import net.luckperms.api.model.group.Group;
import net.luckperms.api.model.user.User;
import net.luckperms.api.node.NodeType;
import net.luckperms.api.node.types.InheritanceNode;
import net.luckperms.api.query.QueryOptions;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.*;
/**
* Shows each player's LuckPerms rank in SCOPENET (profiles, the panel) and, if an admin
* opts in, keeps mapped groups in step with panel groups. LuckPerms stays in charge of
* permissions: the panel only ever adds or removes membership of groups an admin has
* explicitly mapped, and only when {@code integrations.luckperms.apply-panel-groups} is true.
*/
public final class LuckPermsModule implements IntegrationModule {
private LuckPerms luckPerms;
private CompatContext context;
private int task = -1;
private volatile String lastMode = "off";
private volatile int lastPlayers;
@Override public String id() { return "luckperms"; }
@Override public String displayName() { return "LuckPerms"; }
@Override public String pluginName() { return "LuckPerms"; }
@Override public void enable(CompatContext context) {
this.context = context;
this.luckPerms = LuckPermsProvider.get();
long period = 20L * Math.max(15, context.getInt("luckperms", "report-interval-seconds", 60));
// Who is online is read on the server thread; LuckPerms and the panel are talked to off it.
task = context.everySync(100, period, () -> {
List<UUID> online = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) online.add(p.getUniqueId());
context.async(() -> report(online));
});
}
@Override public void disable() {
if (context != null && task >= 0) context.cancel(task);
task = -1;
}
@Override public Map<String, Object> details() {
return Map.of("sync_mode", lastMode, "players_reported", lastPlayers, "apply_panel_groups", context != null && context.getBoolean("luckperms", "apply-panel-groups", false));
}
private void report(List<UUID> online) {
try {
JsonObject data = new JsonObject();
JsonArray groups = new JsonArray();
for (Group g : luckPerms.getGroupManager().getLoadedGroups()) {
JsonObject o = new JsonObject();
o.addProperty("name", g.getName());
if (g.getDisplayName() != null) o.addProperty("display", g.getDisplayName());
o.addProperty("weight", g.getWeight().orElse(0));
groups.add(o);
}
data.add("groups", groups);
JsonArray players = new JsonArray();
for (UUID id : online) {
User user = luckPerms.getUserManager().getUser(id);
if (user == null) continue;
JsonObject p = new JsonObject();
p.addProperty("uuid", id.toString());
String primary = user.getPrimaryGroup();
p.addProperty("primary", primary);
Group primaryGroup = luckPerms.getGroupManager().getGroup(primary);
if (primaryGroup != null) {
p.addProperty("display", primaryGroup.getDisplayName() != null ? primaryGroup.getDisplayName() : primaryGroup.getName());
p.addProperty("weight", primaryGroup.getWeight().orElse(0));
}
CachedMetaData meta = user.getCachedData().getMetaData();
if (meta.getPrefix() != null) p.addProperty("prefix", meta.getPrefix());
if (meta.getSuffix() != null) p.addProperty("suffix", meta.getSuffix());
JsonArray memberOf = new JsonArray();
for (Group g : user.getInheritedGroups(QueryOptions.nonContextual())) memberOf.add(g.getName());
p.add("groups", memberOf);
// Only permissions set directly on the player; group permissions are the group's business.
JsonArray nodes = new JsonArray();
int shown = 0;
for (var node : user.getNodes(NodeType.PERMISSION)) {
if (shown++ >= 40) break;
nodes.add((node.getValue() ? "" : "-") + node.getKey());
}
p.add("permissions", nodes);
players.add(p);
}
data.add("players", players);
lastPlayers = players.size();
JsonObject answer = context.report("luckperms", Bukkit.getPluginManager().getPlugin("LuckPerms").getDescription().getVersion(), data);
JsonObject config = answer.has("config") && answer.get("config").isJsonObject() ? answer.getAsJsonObject("config") : new JsonObject();
if (config.has("mode")) lastMode = config.get("mode").getAsString();
if (context.getBoolean("luckperms", "apply-panel-groups", false) && config.has("assign")) apply(config.getAsJsonArray("assign"));
} catch (Exception e) {
context.log().fine("LuckPerms report failed: " + e.getMessage());
}
}
/** Add or remove membership of mapped groups. Nothing else about a player's permissions is touched. */
private void apply(JsonArray assignments) {
for (JsonElement element : assignments) {
JsonObject a = element.getAsJsonObject();
UUID uuid = UUID.fromString(a.get("uuid").getAsString());
List<String> add = strings(a, "add"), remove = strings(a, "remove");
luckPerms.getUserManager().modifyUser(uuid, user -> {
for (String group : add) {
if (luckPerms.getGroupManager().getGroup(group) != null) user.data().add(InheritanceNode.builder(group).build());
}
for (String group : remove) user.data().remove(InheritanceNode.builder(group).build());
});
}
}
private static List<String> strings(JsonObject o, String key) {
List<String> out = new ArrayList<>();
if (o.has(key) && o.get(key).isJsonArray()) o.getAsJsonArray(key).forEach(e -> out.add(e.getAsString()));
return out;
}
}
@@ -0,0 +1,29 @@
package net.scopenet.paper.compat;
import java.util.Map;
/** Registers the {@code %scopenet_…%} placeholders with PlaceholderAPI. */
public final class PlaceholderApiModule implements IntegrationModule {
private ScopenetExpansion expansion;
@Override public String id() { return "placeholderapi"; }
@Override public String displayName() { return "PlaceholderAPI"; }
@Override public String pluginName() { return "PlaceholderAPI"; }
@Override public void enable(CompatContext context) {
String symbol = context.config().getString("economy.currency-symbol", "$");
expansion = new ScopenetExpansion(context.api(), symbol, context.plugin().getDescription().getVersion());
if (!expansion.register()) throw new IllegalStateException("PlaceholderAPI refused the expansion");
}
@Override public void disable() {
if (expansion != null) {
expansion.unregister();
expansion = null;
}
}
@Override public Map<String, Object> details() {
return Map.of("placeholders", ScopenetExpansion.PLACEHOLDERS.size());
}
}
@@ -0,0 +1,57 @@
package net.scopenet.paper.compat;
import java.lang.reflect.Method;
/**
* Small reflection helpers for integrations that talk to plugins without compiling against
* them (WorldGuard, CoreProtect, Spark): these plugins change their APIs between versions,
* and a missing method should degrade one integration, not break SCOPENET.
*/
final class Reflect {
private Reflect() {}
static Object call(Object target, String name, Object... args) throws Exception {
Method m = find(target.getClass(), name, args.length);
if (m == null) throw new NoSuchMethodException(target.getClass().getName() + "." + name);
m.setAccessible(true);
return m.invoke(target, args);
}
/** Calls the public method whose single parameter accepts {@code arg} (for overloaded one-argument methods). */
static Object callCompatible(Object target, String name, Object arg) throws Exception {
for (Method m : target.getClass().getMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == 1 && m.getParameterTypes()[0].isInstance(arg)) {
m.setAccessible(true);
return m.invoke(target, arg);
}
}
throw new NoSuchMethodException(target.getClass().getName() + "." + name + "(" + arg.getClass().getName() + ")");
}
/** Like {@link #call} but returns {@code null} instead of throwing. */
static Object tryCall(Object target, String name, Object... args) {
try { return target == null ? null : call(target, name, args); }
catch (Exception e) { return null; }
}
/** First public method with this name and arity, searching interfaces too (implementation classes may be non-public). */
static Method find(Class<?> type, String name, int arity) {
for (Method m : type.getMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == arity) return m;
}
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == arity) return m;
}
}
return null;
}
static double number(Object value, double fallback) {
return value instanceof Number n ? n.doubleValue() : fallback;
}
static Class<?> load(String name, ClassLoader loader) throws ClassNotFoundException {
return Class.forName(name, true, loader);
}
}
@@ -0,0 +1,210 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.api.*;
import net.scopenet.integration.PanelClient;
import java.util.*;
import java.util.concurrent.*;
/**
* The developer API, backed by the panel. Network calls run on a small worker
* pool, so nothing here blocks the server thread.
*/
public final class ScopenetApiImpl implements ScopenetApi, AutoCloseable {
private record Cached(ScopenetPlayer player, long loadedAt) {}
private static final long STALE_MS = 30_000;
private final PanelClient client;
private final ExecutorService io = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(512), r -> {
Thread t = new Thread(r, "scopenet-api");
t.setDaemon(true);
return t;
});
private final Map<UUID, Cached> cache = new ConcurrentHashMap<>();
private final Set<UUID> refreshing = ConcurrentHashMap.newKeySet();
private volatile String rankNote = "";
public ScopenetApiImpl(PanelClient client) {
this.client = client;
}
// ---- JSON helpers (tolerant: a missing field is a default, never a crash) ----
private static JsonObject obj(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null;
}
private static String str(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsString() : null;
}
private static long num(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : 0L;
}
private static double dbl(JsonObject o, String key) {
JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : 0.0;
}
private static QuestProgress quests(JsonObject all, String period) {
JsonObject q = obj(obj(all, "quests"), period);
return new QuestProgress((int) num(q, "total"), (int) num(q, "completed"), (int) num(q, "claimed"));
}
/** Turns a {@code player/info} response into a profile, or {@code null} if the player has no account. */
static ScopenetPlayer player(JsonObject j) {
if (j.has("exists") && !j.get("exists").getAsBoolean()) return null;
JsonObject global = obj(j, "global"), server = obj(j, "server"), guild = obj(j, "guild"), rank = obj(j, "rank");
return new ScopenetPlayer(
UUID.fromString(str(j, "uuid")), str(j, "name"),
(int) num(global, "level"), num(global, "xp"), num(global, "current_level_xp"), num(global, "next_level_xp"), dbl(global, "progress_pct"),
str(global, "title"), (int) num(global, "rank"),
(int) num(server, "level"), num(server, "xp"), str(server, "rank_name"),
guild == null ? null : new GuildSummary(str(guild, "id"), str(guild, "name"), str(guild, "tag"), str(guild, "role"), (int) num(guild, "claims")),
dbl(j, "balance"), num(j, "playtime_secs"), num(j, "server_playtime_secs"), (int) num(j, "kills"), (int) num(j, "deaths"),
(int) num(j, "friends"), (int) num(j, "achievements"), quests(j, "daily"), quests(j, "weekly"),
str(j, "joined"), str(rank, "group"), str(rank, "prefix"));
}
static ScopenetGuild guild(JsonObject j) {
if (!j.has("exists") || !j.get("exists").getAsBoolean()) return null;
List<ScopenetGuild.Member> members = new ArrayList<>();
for (JsonElement e : j.getAsJsonArray("members")) {
JsonObject m = e.getAsJsonObject();
members.add(new ScopenetGuild.Member(UUID.fromString(str(m, "uuid")), str(m, "name"), str(m, "role")));
}
return new ScopenetGuild(str(j, "id"), str(j, "name"), str(j, "tag"), str(j, "description"), UUID.fromString(str(j, "leader")),
(int) num(j, "claims"), dbl(j, "balance"), List.copyOf(members));
}
private static JsonObject who(UUID uuid) {
JsonObject o = new JsonObject();
o.addProperty("uuid", uuid.toString());
return o;
}
private <T> CompletableFuture<T> async(Callable<T> call) {
CompletableFuture<T> future = new CompletableFuture<>();
try {
io.execute(() -> {
try { future.complete(call.call()); }
catch (Throwable t) { future.completeExceptionally(t); }
});
} catch (RejectedExecutionException e) {
future.completeExceptionally(new IllegalStateException("SCOPENET is shutting down or overloaded"));
}
return future;
}
// ---- ScopenetApi ----
@Override public CompletableFuture<Optional<ScopenetPlayer>> getPlayer(UUID uuid) {
return async(() -> {
ScopenetPlayer p = player(client.post("player/info", who(uuid)));
if (p != null) cache.put(uuid, new Cached(p, System.currentTimeMillis()));
return Optional.ofNullable(p);
});
}
@Override public Optional<ScopenetPlayer> getCachedPlayer(UUID uuid) {
Cached c = cache.get(uuid);
return c == null ? Optional.empty() : Optional.of(c.player());
}
/**
* Cached profile for scoreboards and placeholders: returns what we have at once and
* quietly refreshes it in the background when it is getting old.
*/
public Optional<ScopenetPlayer> peek(UUID uuid) {
Cached c = cache.get(uuid);
if ((c == null || System.currentTimeMillis() - c.loadedAt() > STALE_MS) && refreshing.add(uuid)) {
getPlayer(uuid).whenComplete((p, e) -> refreshing.remove(uuid));
}
return c == null ? Optional.empty() : Optional.of(c.player());
}
public void invalidate(UUID uuid) {
cache.remove(uuid);
}
public void forget(UUID uuid) {
cache.remove(uuid);
refreshing.remove(uuid);
}
@Override public CompletableFuture<Optional<ScopenetGuild>> getGuild(String idTagOrName) {
return async(() -> {
JsonObject q = new JsonObject();
q.addProperty("guild", idTagOrName);
return Optional.ofNullable(guild(client.post("guild/info", q)));
});
}
@Override public CompletableFuture<Optional<ScopenetGuild>> getPlayerGuild(UUID uuid) {
return async(() -> {
JsonObject q = new JsonObject();
q.addProperty("member", uuid.toString());
return Optional.ofNullable(guild(client.post("guild/info", q)));
});
}
@Override public CompletableFuture<Double> getBalance(UUID uuid) {
return async(() -> client.getBalance(uuid, ""));
}
@Override public CompletableFuture<Double> addBalance(UUID uuid, String playerName, double amount, String reason) {
return async(() -> client.addBalance(uuid, playerName == null ? "" : playerName, amount, reason == null ? "Plugin" : reason));
}
@Override public CompletableFuture<XpResult> addXP(UUID uuid, XpScope scope, long amount, String reason) {
return async(() -> {
JsonObject body = who(uuid);
body.addProperty("operation_id", UUID.randomUUID().toString());
body.addProperty("scope", scope == XpScope.SERVER ? "server" : "global");
body.addProperty("amount", amount);
body.addProperty("reason", reason == null ? "" : reason);
JsonObject r = client.post("player/xp", body);
invalidate(uuid);
return new XpResult(scope, num(r, "xp"), (int) num(r, "level"), (int) num(r, "previous_level"));
});
}
@Override public CompletableFuture<Boolean> completeQuestObjective(UUID uuid, String objective, int amount) {
return async(() -> {
JsonObject body = who(uuid);
body.addProperty("operation_id", UUID.randomUUID().toString());
body.addProperty("objective", objective);
body.addProperty("amount", amount);
JsonObject r = client.post("player/quest-objective", body);
invalidate(uuid);
return r.has("advanced") && r.get("advanced").getAsBoolean();
});
}
@Override public CompletableFuture<List<ScopenetFriend>> getFriends(UUID uuid) {
return async(() -> {
List<ScopenetFriend> out = new ArrayList<>();
JsonArray list = client.post("player/friends", who(uuid)).getAsJsonArray("friends");
for (JsonElement e : list) {
JsonObject f = e.getAsJsonObject();
out.add(new ScopenetFriend(UUID.fromString(str(f, "uuid")), str(f, "name"), f.get("online").getAsBoolean(), str(f, "playing_on")));
}
return List.copyOf(out);
});
}
@Override public void close() {
io.shutdownNow();
}
@FunctionalInterface
private interface Callable<T> {
T call() throws Exception;
}
}
@@ -0,0 +1,173 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonObject;
import net.milkbowl.vault.economy.Economy;
import net.milkbowl.vault.economy.EconomyResponse;
import net.milkbowl.vault.economy.EconomyResponse.ResponseType;
import net.scopenet.integration.OperationLedger;
import net.scopenet.integration.PanelClient;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* SCOPENET's server economy as a Vault provider, so shops, jobs, auction and every other
* Vault plugin use (and pay into) the same balances as /balance and the launcher.
*
* Vault calls are synchronous and arrive on the server thread, so balances are answered from a
* short-lived cache, and changes are applied to the cache immediately and sent to the panel
* through a durable {@link OperationLedger} (saved first, retried until accepted, applied once).
*/
public final class ScopenetEconomy implements Economy {
private record Balance(double amount, long at) {}
private static final long REFRESH_MS = 10_000;
private final CompatContext context;
private final PanelClient client;
private final OperationLedger ledger;
private final String symbol;
private final Map<UUID, Balance> cache = new ConcurrentHashMap<>();
private final Map<String, UUID> inFlight = new ConcurrentHashMap<>();
private final Map<UUID, Boolean> refreshing = new ConcurrentHashMap<>();
public ScopenetEconomy(CompatContext context, OperationLedger ledger, String symbol) {
this.context = context;
this.client = context.integration().client();
this.ledger = ledger;
this.symbol = symbol;
}
/** Ledger callback: the panel settled an operation, so trust its balance again. */
public void settled(String operationId, JsonObject result) {
UUID uuid = inFlight.remove(operationId);
if (uuid == null) return;
if (result.has("error")) {
context.log().warning("The panel refused a Vault transaction for " + uuid + ": " + result.get("error").getAsString());
cache.remove(uuid);
} else if (result.has("balance") && result.get("balance").isJsonPrimitive()) {
// Only adopt the panel's number when nothing else is still in flight for this player.
if (!inFlight.containsValue(uuid)) cache.put(uuid, new Balance(result.get("balance").getAsDouble(), System.currentTimeMillis()));
}
}
private double balanceOf(UUID uuid) {
Balance cached = cache.get(uuid);
if (cached == null) {
// First sight of this player: Vault needs an answer now, so ask the panel directly (briefly).
try {
double fetched = client.getBalance(uuid, "");
cache.put(uuid, new Balance(fetched, System.currentTimeMillis()));
return fetched;
} catch (Exception e) {
return 0.0;
}
}
if (System.currentTimeMillis() - cached.at() > REFRESH_MS && !inFlight.containsValue(uuid) && refreshing.putIfAbsent(uuid, true) == null) {
context.async(() -> {
try { cache.put(uuid, new Balance(client.getBalance(uuid, ""), System.currentTimeMillis())); }
catch (Exception ignored) { /* keep serving the cached value */ }
finally { refreshing.remove(uuid); }
});
}
return cached.amount();
}
private static double round(double amount) {
return Math.round(amount * 100.0) / 100.0;
}
private EconomyResponse change(OfflinePlayer player, double amount, boolean deposit) {
if (Double.isNaN(amount) || Double.isInfinite(amount) || amount < 0) {
return new EconomyResponse(0, balanceOf(player.getUniqueId()), ResponseType.FAILURE, "Invalid amount");
}
amount = round(amount);
UUID uuid = player.getUniqueId();
double before = balanceOf(uuid);
if (!deposit && before + 1e-9 < amount) {
return new EconomyResponse(0, before, ResponseType.FAILURE, "Insufficient funds");
}
if (amount == 0) return new EconomyResponse(0, before, ResponseType.SUCCESS, "");
double after = round(before + (deposit ? amount : -amount));
JsonObject payload = new JsonObject();
payload.addProperty("uuid", uuid.toString());
payload.addProperty("username", player.getName() == null ? uuid.toString() : player.getName());
payload.addProperty("delta", deposit ? amount : -amount);
payload.addProperty("description", "Vault: " + (deposit ? "deposit" : "withdrawal"));
try {
String id = ledger.submit("economy/adjust", payload);
inFlight.put(id, uuid);
} catch (Exception e) {
return new EconomyResponse(0, before, ResponseType.FAILURE, "Could not save the transaction: " + e.getMessage());
}
cache.put(uuid, new Balance(after, System.currentTimeMillis()));
return new EconomyResponse(amount, after, ResponseType.SUCCESS, "");
}
@SuppressWarnings("deprecation")
private static OfflinePlayer named(String name) {
return Bukkit.getOfflinePlayer(name);
}
// ---- Economy ----
@Override public boolean isEnabled() { return context.plugin().isEnabled(); }
@Override public String getName() { return "SCOPENET"; }
@Override public boolean hasBankSupport() { return false; }
@Override public int fractionalDigits() { return 2; }
@Override public String format(double amount) { return symbol + String.format(Locale.US, "%,.2f", amount); }
@Override public String currencyNamePlural() { return "dollars"; }
@Override public String currencyNameSingular() { return "dollar"; }
@Override public boolean hasAccount(String playerName) { return true; }
@Override public boolean hasAccount(OfflinePlayer player) { return true; }
@Override public boolean hasAccount(String playerName, String worldName) { return true; }
@Override public boolean hasAccount(OfflinePlayer player, String worldName) { return true; }
@Override public double getBalance(String playerName) { return balanceOf(named(playerName).getUniqueId()); }
@Override public double getBalance(OfflinePlayer player) { return balanceOf(player.getUniqueId()); }
@Override public double getBalance(String playerName, String world) { return getBalance(playerName); }
@Override public double getBalance(OfflinePlayer player, String world) { return getBalance(player); }
@Override public boolean has(String playerName, double amount) { return getBalance(playerName) + 1e-9 >= amount; }
@Override public boolean has(OfflinePlayer player, double amount) { return getBalance(player) + 1e-9 >= amount; }
@Override public boolean has(String playerName, String worldName, double amount) { return has(playerName, amount); }
@Override public boolean has(OfflinePlayer player, String worldName, double amount) { return has(player, amount); }
@Override public EconomyResponse withdrawPlayer(String playerName, double amount) { return change(named(playerName), amount, false); }
@Override public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { return change(player, amount, false); }
@Override public EconomyResponse withdrawPlayer(String playerName, String worldName, double amount) { return withdrawPlayer(playerName, amount); }
@Override public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { return withdrawPlayer(player, amount); }
@Override public EconomyResponse depositPlayer(String playerName, double amount) { return change(named(playerName), amount, true); }
@Override public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { return change(player, amount, true); }
@Override public EconomyResponse depositPlayer(String playerName, String worldName, double amount) { return depositPlayer(playerName, amount); }
@Override public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { return depositPlayer(player, amount); }
// Guild banks are managed with /guild bank, not through Vault's bank API.
private static EconomyResponse noBanks() {
return new EconomyResponse(0, 0, ResponseType.NOT_IMPLEMENTED, "SCOPENET guild banks are managed with /guild bank");
}
@Override public EconomyResponse createBank(String name, String player) { return noBanks(); }
@Override public EconomyResponse createBank(String name, OfflinePlayer player) { return noBanks(); }
@Override public EconomyResponse deleteBank(String name) { return noBanks(); }
@Override public EconomyResponse bankBalance(String name) { return noBanks(); }
@Override public EconomyResponse bankHas(String name, double amount) { return noBanks(); }
@Override public EconomyResponse bankWithdraw(String name, double amount) { return noBanks(); }
@Override public EconomyResponse bankDeposit(String name, double amount) { return noBanks(); }
@Override public EconomyResponse isBankOwner(String name, String playerName) { return noBanks(); }
@Override public EconomyResponse isBankOwner(String name, OfflinePlayer player) { return noBanks(); }
@Override public EconomyResponse isBankMember(String name, String playerName) { return noBanks(); }
@Override public EconomyResponse isBankMember(String name, OfflinePlayer player) { return noBanks(); }
@Override public List<String> getBanks() { return List.of(); }
// Accounts are created on first use by the panel.
@Override public boolean createPlayerAccount(String playerName) { return true; }
@Override public boolean createPlayerAccount(OfflinePlayer player) { return true; }
@Override public boolean createPlayerAccount(String playerName, String worldName) { return true; }
@Override public boolean createPlayerAccount(OfflinePlayer player, String worldName) { return true; }
}
@@ -0,0 +1,125 @@
package net.scopenet.paper.compat;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import net.scopenet.api.GuildSummary;
import net.scopenet.api.QuestProgress;
import net.scopenet.api.ScopenetPlayer;
import org.bukkit.OfflinePlayer;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* {@code %scopenet_<placeholder>%} for PlaceholderAPI: scoreboards, TAB, holograms, chat and menus.
* Values come from the API's cache, so a placeholder never waits on the network; the first request
* for a player answers with a neutral value while the data loads.
*/
public final class ScopenetExpansion extends PlaceholderExpansion {
/** Every placeholder, for docs and /scopenet placeholders. */
public static final List<String> PLACEHOLDERS = List.of(
"level", "xp", "level_xp", "next_level_xp", "level_progress", "level_bar", "title", "leaderboard_rank",
"server_level", "server_xp", "server_rank",
"guild", "guild_tag", "guild_role", "guild_claims", "has_guild",
"balance", "balance_formatted",
"playtime", "playtime_seconds", "playtime_hours", "server_playtime",
"quest_progress", "quest_daily", "quest_weekly", "quest_daily_done", "quest_daily_total", "quest_weekly_done", "quest_weekly_total",
"kills", "deaths", "kdr", "friends", "achievements", "joined", "rank", "rank_prefix");
private final ScopenetApiImpl api;
private final String symbol;
private final String version;
public ScopenetExpansion(ScopenetApiImpl api, String currencySymbol, String version) {
this.api = api;
this.symbol = currencySymbol;
this.version = version;
}
@Override public String getIdentifier() { return "scopenet"; }
@Override public String getAuthor() { return "SCOPENET"; }
@Override public String getVersion() { return version; }
@Override public boolean persist() { return true; }
@Override public String onRequest(OfflinePlayer player, String params) {
if (player == null) return "";
Optional<ScopenetPlayer> found = api.peek(player.getUniqueId());
String key = params.toLowerCase(Locale.ROOT);
if (found.isEmpty()) return neutral(key);
ScopenetPlayer p = found.get();
GuildSummary guild = p.guild();
return switch (key) {
case "level" -> String.valueOf(p.globalLevel());
case "xp" -> String.valueOf(p.globalXp());
case "level_xp" -> String.valueOf(p.globalLevelXp());
case "next_level_xp" -> String.valueOf(p.globalNextLevelXp());
case "level_progress" -> String.valueOf(Math.round(p.globalProgress()));
case "level_bar" -> bar(p.globalProgress());
case "title" -> p.rankTitle() == null ? "" : p.rankTitle();
case "leaderboard_rank" -> p.leaderboard() <= 0 ? "-" : String.valueOf(p.leaderboard());
case "server_level" -> String.valueOf(p.serverLevel());
case "server_xp" -> String.valueOf(p.serverXp());
case "server_rank" -> p.serverRank() == null ? "" : p.serverRank();
case "guild" -> guild == null ? "" : guild.name();
case "guild_tag" -> guild == null ? "" : guild.tag();
case "guild_role" -> guild == null ? "" : guild.role();
case "guild_claims" -> guild == null ? "0" : String.valueOf(guild.claims());
case "has_guild" -> String.valueOf(guild != null);
case "balance" -> String.format(Locale.US, "%.2f", p.balance());
case "balance_formatted" -> symbol + String.format(Locale.US, "%,.2f", p.balance());
case "playtime" -> duration(p.playtimeSeconds());
case "playtime_seconds" -> String.valueOf(p.playtimeSeconds());
case "playtime_hours" -> String.valueOf(p.playtimeSeconds() / 3600);
case "server_playtime" -> duration(p.serverPlaytimeSeconds());
case "quest_progress" -> combined(p.dailyQuests(), p.weeklyQuests());
case "quest_daily" -> p.dailyQuests().display();
case "quest_weekly" -> p.weeklyQuests().display();
case "quest_daily_done" -> String.valueOf(p.dailyQuests().completed());
case "quest_daily_total" -> String.valueOf(p.dailyQuests().total());
case "quest_weekly_done" -> String.valueOf(p.weeklyQuests().completed());
case "quest_weekly_total" -> String.valueOf(p.weeklyQuests().total());
case "kills" -> String.valueOf(p.kills());
case "deaths" -> String.valueOf(p.deaths());
case "kdr" -> String.format(Locale.US, "%.2f", p.killDeathRatio());
case "friends" -> String.valueOf(p.friends());
case "achievements" -> String.valueOf(p.achievements());
case "joined" -> p.joined() == null ? "" : p.joined().length() >= 10 ? p.joined().substring(0, 10) : p.joined();
case "rank" -> p.rankGroup() == null ? "" : p.rankGroup();
case "rank_prefix" -> p.rankPrefix() == null ? "" : p.rankPrefix();
default -> null; // unknown placeholder: PlaceholderAPI leaves it as written
};
}
/** What to show while a player's data is still loading. */
private String neutral(String key) {
return switch (key) {
case "level", "server_level" -> "1";
case "xp", "level_xp", "next_level_xp", "level_progress", "server_xp", "guild_claims", "kills", "deaths", "friends", "achievements", "playtime_seconds", "playtime_hours",
"quest_daily_done", "quest_daily_total", "quest_weekly_done", "quest_weekly_total" -> "0";
case "balance" -> "0.00";
case "balance_formatted" -> symbol + "0.00";
case "kdr" -> "0.00";
case "has_guild" -> "false";
case "quest_progress", "quest_daily", "quest_weekly" -> "0/0";
case "level_bar" -> bar(0);
case "playtime", "server_playtime" -> "0m";
case "leaderboard_rank" -> "-";
default -> "";
};
}
private static String combined(QuestProgress daily, QuestProgress weekly) {
return (daily.completed() + weekly.completed()) + "/" + (daily.total() + weekly.total());
}
static String bar(double percent) {
int filled = (int) Math.max(0, Math.min(10, Math.round(percent / 10.0)));
return "§a" + "■".repeat(filled) + "§7" + "■".repeat(10 - filled);
}
static String duration(long seconds) {
long d = seconds / 86400, h = seconds % 86400 / 3600, m = seconds % 3600 / 60;
if (d > 0) return d + "d " + h + "h";
if (h > 0) return h + "h " + m + "m";
return m + "m";
}
}
@@ -0,0 +1,107 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonObject;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;
import java.util.Map;
/**
* Sends Spark's performance numbers (TPS, tick time, CPU) to the panel so admins can watch
* server health from the dashboard. Spark is read through reflection, so any Spark
* version that has the statistics API works.
*/
public final class SparkModule implements IntegrationModule {
private CompatContext context;
private Object spark;
private int task = -1;
private volatile double lastTps = -1;
@Override public String id() { return "spark"; }
@Override public String displayName() { return "Spark"; }
@Override public String pluginName() { return "spark"; }
@Override @SuppressWarnings({"unchecked", "rawtypes"})
public void enable(CompatContext context) throws Exception {
this.context = context;
Class sparkType = Reflect.load("me.lucko.spark.api.Spark", Bukkit.getPluginManager().getPlugin("spark").getClass().getClassLoader());
RegisteredServiceProvider<?> provider = Bukkit.getServicesManager().getRegistration(sparkType);
if (provider == null) throw new IllegalStateException("Spark's API isn't registered");
spark = provider.getProvider();
task = context.everyAsync(200, 20L * Math.max(10, context.getInt("spark", "report-interval-seconds", 30)), this::report);
}
@Override public void disable() {
if (context != null && task >= 0) context.cancel(task);
task = -1;
spark = null;
}
@Override public Map<String, Object> details() {
return lastTps < 0 ? Map.of() : Map.of("tps", Math.round(lastTps * 100) / 100.0);
}
/** Reads one window of a statistic, e.g. poll(TicksPerSecond.MINUTES_1). Returns null if it isn't available. */
private static Object poll(Object statistic, String windowType, String window) {
if (statistic == null) return null;
try {
Class<?> type = Class.forName("me.lucko.spark.api.statistic.StatisticWindow$" + windowType, true, statistic.getClass().getClassLoader());
for (Object constant : type.getEnumConstants()) {
if (constant.toString().equals(window)) return Reflect.call(statistic, "poll", constant);
}
} catch (Exception ignored) { /* this Spark doesn't have that window */ }
return null;
}
private static void put(JsonObject to, String key, Object value) {
if (value instanceof Number n && !Double.isNaN(n.doubleValue())) to.addProperty(key, Math.round(n.doubleValue() * 100) / 100.0);
}
private static JsonObject average(Object info) {
JsonObject o = new JsonObject();
if (info == null) return o;
put(o, "mean", Reflect.tryCall(info, "mean"));
put(o, "median", Reflect.tryCall(info, "median"));
put(o, "p95", Reflect.tryCall(info, "percentile95th"));
put(o, "max", Reflect.tryCall(info, "max"));
return o;
}
private void report() {
try {
JsonObject data = new JsonObject();
JsonObject tps = new JsonObject();
Object tpsStat = Reflect.tryCall(spark, "tps");
for (String[] w : new String[][]{{"SECONDS_5", "s5"}, {"SECONDS_10", "s10"}, {"MINUTES_1", "m1"}, {"MINUTES_5", "m5"}, {"MINUTES_15", "m15"}}) {
put(tps, w[1], poll(tpsStat, "TicksPerSecond", w[0]));
}
data.add("tps", tps);
if (tps.has("m1")) lastTps = tps.get("m1").getAsDouble();
JsonObject mspt = new JsonObject();
Object msptStat = Reflect.tryCall(spark, "mspt");
mspt.add("s10", average(poll(msptStat, "MillisPerTick", "SECONDS_10")));
mspt.add("m1", average(poll(msptStat, "MillisPerTick", "MINUTES_1")));
data.add("mspt", mspt);
JsonObject cpu = new JsonObject();
for (String kind : new String[]{"process", "system"}) {
JsonObject o = new JsonObject();
Object stat = Reflect.tryCall(spark, kind.equals("process") ? "cpuProcess" : "cpuSystem");
put(o, "s10", poll(stat, "CpuUsage", "SECONDS_10"));
put(o, "m1", poll(stat, "CpuUsage", "MINUTES_1"));
put(o, "m15", poll(stat, "CpuUsage", "MINUTES_15"));
cpu.add(kind, o);
}
data.add("cpu", cpu);
Runtime rt = Runtime.getRuntime();
JsonObject memory = new JsonObject();
memory.addProperty("used_mb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024));
memory.addProperty("max_mb", rt.maxMemory() / (1024 * 1024));
data.add("memory", memory);
context.report("spark", Bukkit.getPluginManager().getPlugin("spark").getDescription().getVersion(), data);
} catch (Exception e) {
context.log().fine("Spark report failed: " + e.getMessage());
}
}
}
@@ -0,0 +1,48 @@
package net.scopenet.paper.compat;
import net.milkbowl.vault.economy.Economy;
import net.scopenet.integration.OperationLedger;
import org.bukkit.Bukkit;
import org.bukkit.plugin.ServicePriority;
import java.util.Map;
/** Registers the SCOPENET economy as Vault's economy provider. */
public final class VaultModule implements IntegrationModule {
private ScopenetEconomy economy;
private OperationLedger ledger;
private CompatContext context;
@Override public String id() { return "vault"; }
@Override public String displayName() { return "Vault"; }
@Override public String pluginName() { return "Vault"; }
@Override public void enable(CompatContext context) throws Exception {
this.context = context;
String symbol = context.config().getString("economy.currency-symbol", "$");
// The economy needs the ledger's callback and the ledger needs the economy: break the cycle with a holder.
ScopenetEconomy[] holder = new ScopenetEconomy[1];
ledger = new OperationLedger(context.dataFolder().resolve("vault-pending.json"), context.integration().client(),
context.log()::warning, (id, result) -> {
if (holder[0] != null) holder[0].settled(id, result);
});
economy = new ScopenetEconomy(context, ledger, symbol);
holder[0] = economy;
// High priority: if another economy is installed too, admins can still pick with Vault's own config.
Bukkit.getServicesManager().register(Economy.class, economy, context.plugin(), ServicePriority.High);
}
@Override public void disable() {
if (economy != null) {
Bukkit.getServicesManager().unregister(Economy.class, economy);
economy = null;
}
if (ledger != null) {
ledger.close();
ledger = null;
}
}
@Override public Map<String, Object> details() {
return Map.of("unsent_transactions", ledger == null ? 0 : ledger.size());
}
}
@@ -0,0 +1,149 @@
package net.scopenet.paper.compat;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.bukkit.Bukkit;
import org.bukkit.World;
import java.lang.reflect.Method;
import java.util.*;
/**
* Sends WorldGuard's regions (who owns what, where, with which flags) to the panel, so admins
* can review claims and server-owned territory next to guild claims. WorldGuard is read through
* reflection, which keeps SCOPENET working across WorldGuard/WorldEdit versions.
*/
public final class WorldGuardModule implements IntegrationModule {
private static final int MAX_REGIONS = 1500;
private CompatContext context;
private int task = -1;
private volatile int lastCount;
@Override public String id() { return "worldguard"; }
@Override public String displayName() { return "WorldGuard"; }
@Override public String pluginName() { return "WorldGuard"; }
@Override public void enable(CompatContext context) throws Exception {
this.context = context;
collect(); // fail now, not silently later, if this WorldGuard can't be read
long period = 20L * 60 * Math.max(1, context.getInt("worldguard", "report-interval-minutes", 5));
// Regions are read on the server thread (their managers aren't promised to be thread-safe), sent off it.
task = context.everySync(200, period, () -> {
try {
JsonObject data = collect();
context.async(() -> {
try { context.report("worldguard", Bukkit.getPluginManager().getPlugin("WorldGuard").getDescription().getVersion(), data); }
catch (Exception e) { context.log().fine("WorldGuard report failed: " + e.getMessage()); }
});
} catch (Exception e) {
context.log().fine("Could not read WorldGuard regions: " + e.getMessage());
}
});
}
@Override public void disable() {
if (context != null && task >= 0) context.cancel(task);
task = -1;
}
@Override public Map<String, Object> details() {
return Map.of("regions", lastCount);
}
private static int coordinate(Object point, String axis) {
Object v = Reflect.tryCall(point, "get" + axis);
if (v == null) v = Reflect.tryCall(point, "getBlock" + axis);
return (int) Reflect.number(v, 0);
}
private static JsonArray triple(Object point) {
JsonArray a = new JsonArray();
a.add(coordinate(point, "X"));
a.add(coordinate(point, "Y"));
a.add(coordinate(point, "Z"));
return a;
}
/** Owners and members of a region as readable names (players first, then groups as "*group"). */
private static JsonArray names(Object domain, int limit) {
JsonArray out = new JsonArray();
if (domain == null) return out;
Set<String> seen = new LinkedHashSet<>();
Object ids = Reflect.tryCall(domain, "getUniqueIds");
if (ids instanceof Collection<?> uuids) {
for (Object id : uuids) {
if (id instanceof UUID uuid) {
String name = Bukkit.getOfflinePlayer(uuid).getName();
seen.add(name != null ? name : uuid.toString());
}
}
}
Object legacy = Reflect.tryCall(domain, "getPlayers");
if (legacy instanceof Collection<?> players) players.forEach(p -> seen.add(String.valueOf(p)));
Object groups = Reflect.tryCall(domain, "getGroups");
if (groups instanceof Collection<?> g) g.forEach(x -> seen.add("*" + x));
seen.stream().limit(limit).forEach(out::add);
return out;
}
private static int size(Object domain) {
Object n = Reflect.tryCall(domain, "size");
return (int) Reflect.number(n, 0);
}
@SuppressWarnings("unchecked")
private JsonObject collect() throws Exception {
Object wg = Reflect.load("com.sk89q.worldguard.WorldGuard", Bukkit.getPluginManager().getPlugin("WorldGuard").getClass().getClassLoader())
.getMethod("getInstance").invoke(null);
Object container = Reflect.call(Reflect.call(wg, "getPlatform"), "getRegionContainer");
Class<?> adapter = Reflect.load("com.sk89q.worldedit.bukkit.BukkitAdapter", Bukkit.getPluginManager().getPlugin("WorldEdit") != null
? Bukkit.getPluginManager().getPlugin("WorldEdit").getClass().getClassLoader()
: Bukkit.getPluginManager().getPlugin("WorldGuard").getClass().getClassLoader());
Method adapt = adapter.getMethod("adapt", World.class);
List<JsonObject> regions = new ArrayList<>();
boolean truncated = false;
for (World world : Bukkit.getWorlds()) {
Object manager = Reflect.callCompatible(container, "get", adapt.invoke(null, world));
if (manager == null) continue;
Object map = Reflect.call(manager, "getRegions");
if (!(map instanceof Map<?, ?> all)) continue;
for (Object region : all.values()) {
if (regions.size() >= MAX_REGIONS) { truncated = true; break; }
JsonObject r = new JsonObject();
r.addProperty("world", world.getName());
r.addProperty("id", String.valueOf(Reflect.tryCall(region, "getId")));
Object type = Reflect.tryCall(region, "getType");
r.addProperty("type", type == null ? "cuboid" : type.toString().toLowerCase(Locale.ROOT));
r.add("min", triple(Reflect.tryCall(region, "getMinimumPoint")));
r.add("max", triple(Reflect.tryCall(region, "getMaximumPoint")));
r.addProperty("priority", (int) Reflect.number(Reflect.tryCall(region, "getPriority"), 0));
Object parent = Reflect.tryCall(region, "getParent");
if (parent != null) r.addProperty("parent", String.valueOf(Reflect.tryCall(parent, "getId")));
Object owners = Reflect.tryCall(region, "getOwners");
r.add("owners", names(owners, 4));
r.addProperty("owner_count", size(owners));
r.addProperty("member_count", size(Reflect.tryCall(region, "getMembers")));
JsonObject flags = new JsonObject();
Object flagMap = Reflect.tryCall(region, "getFlags");
if (flagMap instanceof Map<?, ?> fm) {
int shown = 0;
for (Map.Entry<?, ?> f : fm.entrySet()) {
if (shown++ >= 12) break;
String value = String.valueOf(f.getValue());
flags.addProperty(String.valueOf(Reflect.tryCall(f.getKey(), "getName")), value.length() > 60 ? value.substring(0, 60) : value);
}
}
r.add("flags", flags);
regions.add(r);
}
}
regions.sort((a, b) -> Integer.compare(b.get("priority").getAsInt(), a.get("priority").getAsInt()));
JsonArray list = new JsonArray();
regions.forEach(list::add);
lastCount = regions.size();
JsonObject data = new JsonObject();
data.add("regions", list);
data.addProperty("truncated", truncated);
return data;
}
}
@@ -63,3 +63,33 @@ economy:
# Currency symbol
currency-symbol: "$"
# ==============================================================================
# Plugin integrations
# SCOPENET detects these plugins by itself; switch any of them off here.
# ==============================================================================
integrations:
luckperms:
enabled: true
# Send each online player's LuckPerms rank to the panel (profiles, admin view).
report-interval-seconds: 60
# Let the panel add/remove LuckPerms group membership for groups an admin has mapped to a panel
# group (Panel > Players > Groups). LuckPerms stays in charge of permissions; this is off unless you opt in.
apply-panel-groups: false
placeholderapi:
# %scopenet_level%, %scopenet_guild%, %scopenet_balance%, %scopenet_playtime%, %scopenet_quest_progress% ...
enabled: true
vault:
# Use the SCOPENET economy as Vault's economy, so other plugins' shops and jobs pay into it.
enabled: true
coreprotect:
enabled: true
# Folders scanned for world backups shown in the panel.
backup-folders:
- backups
worldguard:
enabled: true
report-interval-minutes: 5
spark:
enabled: true
report-interval-seconds: 30
@@ -4,6 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
commands:
scopenet: