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

+7
View File
@@ -0,0 +1,7 @@
// The public SCOPENET API: depend on this (compileOnly) from your own plugin.
// It is bundled inside the SCOPENET plugin jar, so there is nothing to shade.
plugins { id 'java-library' }
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
dependencies {
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
}
@@ -0,0 +1,7 @@
package net.scopenet.api;
/** A player's guild, as seen from the server they are on. */
public record GuildSummary(String id, String name, String tag, String role, int claims) {
public boolean isLeader() { return "leader".equalsIgnoreCase(role); }
public boolean isOfficer() { return isLeader() || "officer".equalsIgnoreCase(role); }
}
@@ -0,0 +1,7 @@
package net.scopenet.api;
/** How many of a player's quests for the current period they have finished. */
public record QuestProgress(int total, int completed, int claimed) {
/** "3/5", handy for scoreboards. */
public String display() { return completed + "/" + total; }
}
@@ -0,0 +1,63 @@
package net.scopenet.api;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* The SCOPENET developer API. Everything that talks to the panel returns a
* {@link CompletableFuture} and never blocks the server thread; the
* {@code getCached…} methods answer instantly from data SCOPENET already holds.
* Futures complete on a worker thread, so hop back with the Bukkit scheduler
* before touching the world.
*
* <pre>{@code
* ScopenetApi api = ScopenetApiProvider.get();
* api.getPlayer(player.getUniqueId()).thenAccept(p -> getLogger().info(p.name() + " is level " + p.globalLevel()));
* api.addXP(player.getUniqueId(), XpScope.SERVER, 250, "boss kill");
* }</pre>
*
* Events ({@link net.scopenet.api.event.ScopenetLevelUpEvent},
* {@link net.scopenet.api.event.GuildJoinEvent},
* {@link net.scopenet.api.event.GuildLeaveEvent},
* {@link net.scopenet.api.event.AchievementUnlockEvent}) are ordinary Bukkit events.
*/
public interface ScopenetApi {
/** Bumped when the API changes incompatibly. */
int API_VERSION = 1;
/** Load a player's SCOPENET profile. Completes with empty if they have no SCOPENET account. */
CompletableFuture<Optional<ScopenetPlayer>> getPlayer(UUID uuid);
/** The last profile loaded for a player (kept fresh for online players), if any. Never blocks. */
Optional<ScopenetPlayer> getCachedPlayer(UUID uuid);
/** Find a guild on this server's instance by id, tag or name. */
CompletableFuture<Optional<ScopenetGuild>> getGuild(String idTagOrName);
/** The guild a player belongs to. */
CompletableFuture<Optional<ScopenetGuild>> getPlayerGuild(UUID uuid);
/** A player's balance on this server's economy. */
CompletableFuture<Double> getBalance(UUID uuid);
/**
* Give (positive) or take (negative) money. Completes with the new balance, or
* exceptionally if the player can't afford it.
*/
CompletableFuture<Double> addBalance(UUID uuid, String playerName, double amount, String reason);
/** Give (positive) or take (negative) XP. Level-ups fire {@link net.scopenet.api.event.ScopenetLevelUpEvent}. */
CompletableFuture<XpResult> addXP(UUID uuid, XpScope scope, long amount, String reason);
/**
* Move a quest forward. {@code objective} is a quest id (with {@code amount} 0 meaning "complete it")
* or an action like {@code block_broken:DIAMOND_ORE} that any matching quest counts.
* Completes with whether any quest advanced.
*/
CompletableFuture<Boolean> completeQuestObjective(UUID uuid, String objective, int amount);
/** A player's accepted friends. */
CompletableFuture<List<ScopenetFriend>> getFriends(UUID uuid);
}
@@ -0,0 +1,25 @@
package net.scopenet.api;
/** Where other plugins find the API. Also registered with Bukkit's ServicesManager. */
public final class ScopenetApiProvider {
private static volatile ScopenetApi api;
private ScopenetApiProvider() {}
/** @throws IllegalStateException if SCOPENET isn't loaded (add it to your plugin's depend/softdepend) */
public static ScopenetApi get() {
ScopenetApi current = api;
if (current == null) throw new IllegalStateException("SCOPENET is not enabled");
return current;
}
public static boolean isAvailable() { return api != null; }
/** Called by SCOPENET. */
public static void register(ScopenetApi implementation) { api = implementation; }
/** Called by SCOPENET when it shuts down. */
public static void unregister(ScopenetApi implementation) {
if (api == implementation) api = null;
}
}
@@ -0,0 +1,6 @@
package net.scopenet.api;
import java.util.UUID;
/** An accepted friend, and where they are playing right now (if anywhere). */
public record ScopenetFriend(UUID uuid, String name, boolean online, String playingOn) {}
@@ -0,0 +1,13 @@
package net.scopenet.api;
import java.util.List;
import java.util.UUID;
/** A guild and its members. Balances and claim counts are for the server the plugin runs on. */
public record ScopenetGuild(String id, String name, String tag, String description, UUID leader, int claims, double balance, List<Member> members) {
public record Member(UUID uuid, String name, String role) {}
public boolean isMember(UUID player) {
return members.stream().anyMatch(m -> m.uuid().equals(player));
}
}
@@ -0,0 +1,44 @@
package net.scopenet.api;
import java.util.UUID;
/**
* A snapshot of one SCOPENET account. It is a copy: fetch a new one (or use the
* cached copy, which is refreshed every few seconds for online players) to see changes.
*
* @param rankTitle the title earned from global level rewards, or {@code null}
* @param leaderboard the position on the global leaderboard, or 0 when unranked
* @param rankGroup the player's LuckPerms primary group as reported by the server, or {@code null}
* @param guild the player's guild on this server's instance, or {@code null}
*/
public record ScopenetPlayer(
UUID uuid,
String name,
int globalLevel,
long globalXp,
long globalLevelXp,
long globalNextLevelXp,
double globalProgress,
String rankTitle,
int leaderboard,
int serverLevel,
long serverXp,
String serverRank,
GuildSummary guild,
double balance,
long playtimeSeconds,
long serverPlaytimeSeconds,
int kills,
int deaths,
int friends,
int achievements,
QuestProgress dailyQuests,
QuestProgress weeklyQuests,
String joined,
String rankGroup,
String rankPrefix) {
public boolean inGuild() { return guild != null; }
public double killDeathRatio() { return deaths == 0 ? kills : (double) kills / deaths; }
}
@@ -0,0 +1,6 @@
package net.scopenet.api;
/** What a player's XP and level are after {@link ScopenetApi#addXP}. */
public record XpResult(XpScope scope, long xp, int level, int previousLevel) {
public boolean leveledUp() { return level > previousLevel; }
}
@@ -0,0 +1,9 @@
package net.scopenet.api;
/** Which level track an XP change applies to. */
public enum XpScope {
/** The account-wide SCOPENET level, shared by every server. */
GLOBAL,
/** The level on the server the plugin is running on. */
SERVER
}
@@ -0,0 +1,27 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player unlocked a SCOPENET achievement. */
public final class AchievementUnlockEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String achievementId;
private final String title;
private final long xp;
public AchievementUnlockEvent(UUID playerId, String achievementId, String title, long xp) {
super(playerId);
this.achievementId = achievementId;
this.title = title;
this.xp = xp;
}
public String getAchievementId() { return achievementId; }
public String getTitle() { return title; }
/** XP the achievement was worth. */
public long getXp() { return xp; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,30 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player founded or joined a guild. */
public final class GuildJoinEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String guildId;
private final String guildName;
private final String guildTag;
private final String role;
public GuildJoinEvent(UUID playerId, String guildId, String guildName, String guildTag, String role) {
super(playerId);
this.guildId = guildId;
this.guildName = guildName;
this.guildTag = guildTag;
this.role = role;
}
public String getGuildId() { return guildId; }
public String getGuildName() { return guildName; }
public String getGuildTag() { return guildTag; }
/** {@code leader}, {@code officer} or {@code member}. */
public String getRole() { return role; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,27 @@
package net.scopenet.api.event;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player left (or was removed from) a guild. */
public final class GuildLeaveEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String guildId;
private final String guildName;
private final String guildTag;
public GuildLeaveEvent(UUID playerId, String guildId, String guildName, String guildTag) {
super(playerId);
this.guildId = guildId;
this.guildName = guildName;
this.guildTag = guildTag;
}
public String getGuildId() { return guildId; }
/** May be {@code null} if the guild no longer exists. */
public String getGuildName() { return guildName; }
public String getGuildTag() { return guildTag; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -0,0 +1,18 @@
package net.scopenet.api.event;
import org.bukkit.event.Event;
import java.util.UUID;
/**
* Base class for SCOPENET events. They are fired on the server thread when the
* panel reports a change for a player who is on this server.
*/
public abstract class ScopenetEvent extends Event {
private final UUID playerId;
protected ScopenetEvent(UUID playerId) {
this.playerId = playerId;
}
public UUID getPlayerId() { return playerId; }
}
@@ -0,0 +1,30 @@
package net.scopenet.api.event;
import net.scopenet.api.XpScope;
import org.bukkit.event.HandlerList;
import java.util.UUID;
/** A player's global or server level went up. */
public final class ScopenetLevelUpEvent extends ScopenetEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final XpScope scope;
private final int level;
private final int previousLevel;
private final long xp;
public ScopenetLevelUpEvent(UUID playerId, XpScope scope, int level, int previousLevel, long xp) {
super(playerId);
this.scope = scope;
this.level = level;
this.previousLevel = previousLevel;
this.xp = xp;
}
public XpScope getScope() { return scope; }
public int getLevel() { return level; }
public int getPreviousLevel() { return previousLevel; }
public long getXp() { return xp; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; }
}
@@ -19,6 +19,7 @@ public final class Integration implements AutoCloseable {
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy()); new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
private final AtomicBoolean syncing = new AtomicBoolean(); private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed; private volatile boolean closed;
private volatile Consumer<JsonArray> notificationHandler;
private volatile LiveMapSync liveMap; private volatile LiveMapSync liveMap;
private boolean registered; private boolean registered;
private JsonObject pending; private JsonObject pending;
@@ -106,6 +107,12 @@ public final class Integration implements AutoCloseable {
if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick") if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick")
|| !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response"); || !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response");
pending = null; pending = null;
Consumer<JsonArray> handler = notificationHandler;
if (handler != null && response.has("notifications") && response.get("notifications").isJsonArray()
&& response.getAsJsonArray("notifications").size() > 0) {
try { handler.accept(response.getAsJsonArray("notifications")); }
catch (RuntimeException e) { log.accept("SCOPENET could not process panel events: " + e.getMessage()); }
}
for (JsonElement element : response.getAsJsonArray("kick")) { for (JsonElement element : response.getAsJsonArray("kick")) {
try { try {
JsonObject entry = element.getAsJsonObject(); JsonObject entry = element.getAsJsonObject();
@@ -129,6 +136,14 @@ public final class Integration implements AutoCloseable {
return client; return client;
} }
/**
* Receive events the panel queued for this server (level-ups, achievements,
* guild changes) with each sync. Called on the sync worker thread.
*/
public void onNotifications(Consumer<JsonArray> handler) {
this.notificationHandler = handler;
}
/** /**
* Start mirroring this server's world and player positions to the panel's * Start mirroring this server's world and player positions to the panel's
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}). * central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
@@ -0,0 +1,138 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
* A write-ahead queue for panel requests that must not be lost or repeated,
* like the money moved by Vault plugins. Each request gets an operation id and
* is saved to disk before it is sent; it is retried until the panel answers
* (the panel applies an operation id once), and only then forgotten. A panel
* outage or a server crash therefore never drops or duplicates a payment.
*/
public final class OperationLedger implements AutoCloseable {
private record Pending(String endpoint, JsonObject payload) {}
private final Path file;
private final PanelClient client;
private final Consumer<String> log;
private final BiConsumer<String, JsonObject> done;
private final Map<String, Pending> pending = new LinkedHashMap<>();
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-ledger");
t.setDaemon(true);
return t;
});
private volatile boolean closed;
/**
* @param done called with (operation id, response) after the panel accepted an operation, or with
* (operation id, {"error": "..."}) after it permanently refused it
*/
public OperationLedger(Path file, PanelClient client, Consumer<String> log, BiConsumer<String, JsonObject> done) {
this.file = file;
this.client = client;
this.log = log;
this.done = done;
load();
worker.scheduleWithFixedDelay(this::drain, 1, 2, TimeUnit.SECONDS);
}
public synchronized int size() { return pending.size(); }
/** Save the operation and start sending it. Returns its operation id. */
public String submit(String endpoint, JsonObject payload) throws IOException {
String id = UUID.randomUUID().toString();
payload.addProperty("operation_id", id);
synchronized (this) {
pending.put(id, new Pending(endpoint, payload));
try { save(); } catch (IOException e) { pending.remove(id); throw e; }
}
try { worker.execute(this::drain); } catch (RuntimeException ignored) { /* closing */ }
return id;
}
private void drain() {
if (closed) return;
Map<String, Pending> batch;
synchronized (this) { batch = new LinkedHashMap<>(pending); }
for (Map.Entry<String, Pending> entry : batch.entrySet()) {
if (closed) return;
String id = entry.getKey();
JsonObject result;
try {
result = client.post(entry.getValue().endpoint(), entry.getValue().payload().deepCopy());
} catch (PanelClient.HttpFailure e) {
if (e.status >= 400 && e.status < 500 && e.status != 408 && e.status != 429) {
// The panel will never accept this one; report it and move on.
JsonObject error = new JsonObject();
error.addProperty("error", e.getMessage());
finish(id, error);
continue;
}
return; // panel trouble: try again later, keeping the order
} catch (IOException e) {
return; // unreachable: try again later
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
finish(id, result);
}
}
private void finish(String id, JsonObject result) {
synchronized (this) {
pending.remove(id);
try { save(); } catch (IOException e) { log.accept("SCOPENET could not update " + file.getFileName() + ": " + e.getMessage()); }
}
try { done.accept(id, result); } catch (RuntimeException e) { log.accept("SCOPENET operation callback failed: " + e.getMessage()); }
}
private void load() {
try {
if (!Files.isRegularFile(file)) return;
JsonObject root = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
for (Map.Entry<String, com.google.gson.JsonElement> e : root.entrySet()) {
JsonObject op = e.getValue().getAsJsonObject();
pending.put(e.getKey(), new Pending(op.get("endpoint").getAsString(), op.getAsJsonObject("payload")));
}
if (!pending.isEmpty()) log.accept("SCOPENET is resending " + pending.size() + " saved transaction(s)");
} catch (Exception e) {
log.accept("SCOPENET could not read " + file.getFileName() + ": " + e.getMessage());
}
}
private void save() throws IOException {
JsonObject root = new JsonObject();
for (Map.Entry<String, Pending> e : pending.entrySet()) {
JsonObject op = new JsonObject();
op.addProperty("endpoint", e.getValue().endpoint());
op.add("payload", e.getValue().payload());
root.add(e.getKey(), op);
}
Path parent = file.toAbsolutePath().getParent();
if (parent != null) Files.createDirectories(parent);
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
Files.writeString(temp, root.toString(), StandardCharsets.UTF_8);
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
}
@Override public void close() {
closed = true;
worker.shutdownNow();
}
}
@@ -0,0 +1,106 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class OperationLedgerTest {
private static final String TOKEN = "sn_" + "b".repeat(40);
private HttpServer server;
private PanelClient client;
@TempDir Path dir;
@BeforeEach void start() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), TOKEN));
}
@AfterEach void stop() {
client.close();
server.stop(0);
}
private void reply(String path, int[] statuses, AtomicInteger calls, List<String> seen) {
server.createContext("/api/server/v1/" + path, exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
int n = calls.getAndIncrement();
seen.add(JsonParser.parseString(body).getAsJsonObject().get("operation_id").getAsString());
int status = statuses[Math.min(n, statuses.length - 1)];
byte[] out = (status == 200 ? "{\"ok\":true,\"balance\":42}" : "{\"error\":\"nope\"}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, out.length);
exchange.getResponseBody().write(out);
exchange.close();
});
}
private static JsonObject payload() {
JsonObject p = new JsonObject();
p.addProperty("delta", 5);
return p;
}
@Test void retriesWithTheSameIdUntilThePanelAnswers() throws Exception {
AtomicInteger calls = new AtomicInteger();
List<String> ids = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{503, 503, 200}, calls, ids);
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
try (OperationLedger ledger = new OperationLedger(dir.resolve("pending.json"), client, s -> {}, (id, r) -> results.add(r))) {
String id = ledger.submit("economy/adjust", payload());
JsonObject result = results.poll(15, TimeUnit.SECONDS);
assertNotNull(result);
assertEquals(42, result.get("balance").getAsInt());
assertEquals(3, calls.get());
assertEquals(Set.of(id), new HashSet<>(ids), "every retry carries the same operation id");
assertEquals(0, ledger.size());
}
assertEquals("{}", Files.readString(dir.resolve("pending.json")).trim());
}
@Test void permanentRefusalsAreReportedNotRetried() throws Exception {
AtomicInteger calls = new AtomicInteger();
reply("economy/adjust", new int[]{400}, calls, new CopyOnWriteArrayList<>());
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
try (OperationLedger ledger = new OperationLedger(dir.resolve("pending.json"), client, s -> {}, (id, r) -> results.add(r))) {
ledger.submit("economy/adjust", payload());
JsonObject result = results.poll(10, TimeUnit.SECONDS);
assertNotNull(result);
assertEquals("nope", result.get("error").getAsString());
Thread.sleep(2500);
assertEquals(1, calls.get());
}
}
@Test void pendingOperationsSurviveARestart() throws Exception {
Path file = dir.resolve("pending.json");
AtomicInteger calls = new AtomicInteger();
List<String> ids = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{503}, calls, ids);
String id;
try (OperationLedger first = new OperationLedger(file, client, s -> {}, (i, r) -> {})) {
id = first.submit("economy/adjust", payload());
assertTrue(Files.readString(file).contains(id), "saved before anything is sent");
}
server.removeContext("/api/server/v1/economy/adjust");
AtomicInteger ok = new AtomicInteger();
List<String> resent = new CopyOnWriteArrayList<>();
reply("economy/adjust", new int[]{200}, ok, resent);
BlockingQueue<JsonObject> results = new LinkedBlockingQueue<>();
List<String> logs = new CopyOnWriteArrayList<>();
try (OperationLedger second = new OperationLedger(file, client, logs::add, (i, r) -> results.add(r))) {
assertNotNull(results.poll(10, TimeUnit.SECONDS));
assertEquals(List.of(id), resent);
assertTrue(logs.stream().anyMatch(l -> l.contains("resending 1")));
}
}
}
+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 { dependencies {
implementation project(':common') implementation project(':common')
implementation project(':api')
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT' 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 { processResources {
inputs.property 'version', project.version inputs.property 'version', project.version
@@ -12,6 +21,7 @@ jar {
archiveBaseName = 'scopenet-paper' archiveBaseName = 'scopenet-paper'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output 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) } } 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' exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
} }
@@ -1,6 +1,11 @@
package net.scopenet.paper; package net.scopenet.paper;
import net.scopenet.api.ScopenetApi;
import net.scopenet.api.ScopenetApiProvider;
import net.scopenet.integration.*; 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 net.scopenet.paper.commands.*;
import org.bukkit.*; import org.bukkit.*;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -17,6 +22,8 @@ import java.util.UUID;
public final class ScopenetPlugin extends JavaPlugin implements Listener { public final class ScopenetPlugin extends JavaPlugin implements Listener {
private Integration integration; private Integration integration;
private EconomyHandler economy; private EconomyHandler economy;
private ScopenetApiImpl api;
private CompatManager compat;
@Override public void onEnable() { @Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid. // 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); 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 // Register /scopenet admin & help command
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration); ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
bindCommand("scopenet", scopenetCmd); bindCommand("scopenet", scopenetCmd);
// Register Essentials commands & GUI // 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() { @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(); if (integration != null) integration.close();
} }
@@ -180,6 +212,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.MONITOR)
public void leave(PlayerQuitEvent event) { public void leave(PlayerQuitEvent event) {
deniedAt.remove(event.getPlayer().getUniqueId()); 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()); 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 ScopenetPlugin plugin;
private final Integration integration; 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.plugin = plugin;
this.integration = integration; this.integration = integration;
this.compat = compat;
} }
@Override @Override
@@ -116,6 +119,22 @@ public final class ScopenetCommandHandler implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "Social: " + formatFlag(s.socialEnabled())); sender.sendMessage(ChatColor.GRAY + "Social: " + formatFlag(s.socialEnabled()));
sender.sendMessage(ChatColor.GRAY + "Essentials: " + formatFlag(s.essentialsEnabled())); sender.sendMessage(ChatColor.GRAY + "Essentials: " + formatFlag(s.essentialsEnabled()));
sender.sendMessage(ChatColor.GRAY + "Economy: " + formatFlag(s.economyEnabled())); 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) { 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
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' api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting. description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
commands: commands:
scopenet: scopenet:
+1 -1
View File
@@ -6,7 +6,7 @@ pluginManagement {
} }
} }
rootProject.name = 'scopenet-integrations' rootProject.name = 'scopenet-integrations'
include 'common', 'paper' include 'common', 'api', 'paper'
// Build loaders separately: Forge and Loom use different Gradle generations. // Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric' if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge' if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'