diff --git a/integrations/api/build.gradle b/integrations/api/build.gradle
new file mode 100644
index 0000000..5065dfe
--- /dev/null
+++ b/integrations/api/build.gradle
@@ -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'
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/GuildSummary.java b/integrations/api/src/main/java/net/scopenet/api/GuildSummary.java
new file mode 100644
index 0000000..7ed6f13
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/GuildSummary.java
@@ -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); }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/QuestProgress.java b/integrations/api/src/main/java/net/scopenet/api/QuestProgress.java
new file mode 100644
index 0000000..5860c24
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/QuestProgress.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/ScopenetApi.java b/integrations/api/src/main/java/net/scopenet/api/ScopenetApi.java
new file mode 100644
index 0000000..af6e2b7
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/ScopenetApi.java
@@ -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.
+ *
+ *
{@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");
+ * }
+ *
+ * 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> getPlayer(UUID uuid);
+
+ /** The last profile loaded for a player (kept fresh for online players), if any. Never blocks. */
+ Optional getCachedPlayer(UUID uuid);
+
+ /** Find a guild on this server's instance by id, tag or name. */
+ CompletableFuture> getGuild(String idTagOrName);
+
+ /** The guild a player belongs to. */
+ CompletableFuture> getPlayerGuild(UUID uuid);
+
+ /** A player's balance on this server's economy. */
+ CompletableFuture getBalance(UUID uuid);
+
+ /**
+ * Give (positive) or take (negative) money. Completes with the new balance, or
+ * exceptionally if the player can't afford it.
+ */
+ CompletableFuture 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 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 completeQuestObjective(UUID uuid, String objective, int amount);
+
+ /** A player's accepted friends. */
+ CompletableFuture> getFriends(UUID uuid);
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/ScopenetApiProvider.java b/integrations/api/src/main/java/net/scopenet/api/ScopenetApiProvider.java
new file mode 100644
index 0000000..3612544
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/ScopenetApiProvider.java
@@ -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;
+ }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/ScopenetFriend.java b/integrations/api/src/main/java/net/scopenet/api/ScopenetFriend.java
new file mode 100644
index 0000000..6baa37b
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/ScopenetFriend.java
@@ -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) {}
diff --git a/integrations/api/src/main/java/net/scopenet/api/ScopenetGuild.java b/integrations/api/src/main/java/net/scopenet/api/ScopenetGuild.java
new file mode 100644
index 0000000..87cc5ef
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/ScopenetGuild.java
@@ -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 members) {
+ public record Member(UUID uuid, String name, String role) {}
+
+ public boolean isMember(UUID player) {
+ return members.stream().anyMatch(m -> m.uuid().equals(player));
+ }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/ScopenetPlayer.java b/integrations/api/src/main/java/net/scopenet/api/ScopenetPlayer.java
new file mode 100644
index 0000000..3141c2b
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/ScopenetPlayer.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/XpResult.java b/integrations/api/src/main/java/net/scopenet/api/XpResult.java
new file mode 100644
index 0000000..cd3f49d
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/XpResult.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/XpScope.java b/integrations/api/src/main/java/net/scopenet/api/XpScope.java
new file mode 100644
index 0000000..a100729
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/XpScope.java
@@ -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
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/event/AchievementUnlockEvent.java b/integrations/api/src/main/java/net/scopenet/api/event/AchievementUnlockEvent.java
new file mode 100644
index 0000000..9627cf3
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/event/AchievementUnlockEvent.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/event/GuildJoinEvent.java b/integrations/api/src/main/java/net/scopenet/api/event/GuildJoinEvent.java
new file mode 100644
index 0000000..f0e490c
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/event/GuildJoinEvent.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/event/GuildLeaveEvent.java b/integrations/api/src/main/java/net/scopenet/api/event/GuildLeaveEvent.java
new file mode 100644
index 0000000..57cd872
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/event/GuildLeaveEvent.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/event/ScopenetEvent.java b/integrations/api/src/main/java/net/scopenet/api/event/ScopenetEvent.java
new file mode 100644
index 0000000..3f522dd
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/event/ScopenetEvent.java
@@ -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; }
+}
diff --git a/integrations/api/src/main/java/net/scopenet/api/event/ScopenetLevelUpEvent.java b/integrations/api/src/main/java/net/scopenet/api/event/ScopenetLevelUpEvent.java
new file mode 100644
index 0000000..6ecc58e
--- /dev/null
+++ b/integrations/api/src/main/java/net/scopenet/api/event/ScopenetLevelUpEvent.java
@@ -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; }
+}
diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java
index 86da50c..aca37ac 100644
--- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java
+++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java
@@ -19,6 +19,7 @@ public final class Integration implements AutoCloseable {
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed;
+ private volatile Consumer notificationHandler;
private volatile LiveMapSync liveMap;
private boolean registered;
private JsonObject pending;
@@ -106,6 +107,12 @@ public final class Integration implements AutoCloseable {
if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick")
|| !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response");
pending = null;
+ Consumer 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")) {
try {
JsonObject entry = element.getAsJsonObject();
@@ -129,6 +136,14 @@ public final class Integration implements AutoCloseable {
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 handler) {
+ this.notificationHandler = handler;
+ }
+
/**
* 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}).
diff --git a/integrations/common/src/main/java/net/scopenet/integration/OperationLedger.java b/integrations/common/src/main/java/net/scopenet/integration/OperationLedger.java
new file mode 100644
index 0000000..2afcb37
--- /dev/null
+++ b/integrations/common/src/main/java/net/scopenet/integration/OperationLedger.java
@@ -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 log;
+ private final BiConsumer done;
+ private final Map 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 log, BiConsumer 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 batch;
+ synchronized (this) { batch = new LinkedHashMap<>(pending); }
+ for (Map.Entry 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 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 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();
+ }
+}
diff --git a/integrations/common/src/test/java/net/scopenet/integration/OperationLedgerTest.java b/integrations/common/src/test/java/net/scopenet/integration/OperationLedgerTest.java
new file mode 100644
index 0000000..9facd82
--- /dev/null
+++ b/integrations/common/src/test/java/net/scopenet/integration/OperationLedgerTest.java
@@ -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 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 ids = new CopyOnWriteArrayList<>();
+ reply("economy/adjust", new int[]{503, 503, 200}, calls, ids);
+ BlockingQueue 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 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 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 resent = new CopyOnWriteArrayList<>();
+ reply("economy/adjust", new int[]{200}, ok, resent);
+ BlockingQueue results = new LinkedBlockingQueue<>();
+ List 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")));
+ }
+ }
+}
diff --git a/integrations/paper/build.gradle b/integrations/paper/build.gradle
index dd80438..b0c19ed 100644
--- a/integrations/paper/build.gradle
+++ b/integrations/paper/build.gradle
@@ -1,8 +1,17 @@
-repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
+repositories {
+ maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
+ maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' }
+ maven { url = 'https://jitpack.io' }
+}
dependencies {
implementation project(':common')
+ implementation project(':api')
implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
+ // Optional integrations: the plugin detects these at runtime and works without them.
+ compileOnly 'net.luckperms:api:5.4'
+ compileOnly 'me.clip:placeholderapi:2.11.6'
+ compileOnly('com.github.MilkBowl:VaultAPI:1.7.1') { exclude group: 'org.bukkit' }
}
processResources {
inputs.property 'version', project.version
@@ -12,6 +21,7 @@ jar {
archiveBaseName = 'scopenet-paper'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output
+ from project(':api').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java
index 1cc8bb8..3edbbb3 100644
--- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java
+++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java
@@ -1,6 +1,11 @@
package net.scopenet.paper;
+import net.scopenet.api.ScopenetApi;
+import net.scopenet.api.ScopenetApiProvider;
import net.scopenet.integration.*;
+import net.scopenet.paper.compat.CompatManager;
+import net.scopenet.paper.compat.EventBridge;
+import net.scopenet.paper.compat.ScopenetApiImpl;
import net.scopenet.paper.commands.*;
import org.bukkit.*;
import org.bukkit.entity.Player;
@@ -17,6 +22,8 @@ import java.util.UUID;
public final class ScopenetPlugin extends JavaPlugin implements Listener {
private Integration integration;
private EconomyHandler economy;
+ private ScopenetApiImpl api;
+ private CompatManager compat;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -46,8 +53,21 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
}
+ // Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
+ // and receive level-ups, achievements and guild changes as ordinary Bukkit events.
+ api = new ScopenetApiImpl(integration.client());
+ ScopenetApiProvider.register(api);
+ getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
+ integration.onNotifications(new EventBridge(this, api, getLogger())::accept);
+ Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
+
+ // Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
+ // switched on by the compat manager once every plugin has loaded.
+ compat = new CompatManager(this, integration, api);
+ Bukkit.getScheduler().runTask(this, compat::start);
+
// Register /scopenet admin & help command
- ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration);
+ ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
bindCommand("scopenet", scopenetCmd);
// Register Essentials commands & GUI
@@ -152,7 +172,19 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
}
}
+ /** Keep the API's cached profiles of online players fresh, for placeholders and getCachedPlayer(). */
+ private void warmProfiles() {
+ if (api == null) return;
+ for (Player p : Bukkit.getOnlinePlayers()) api.peek(p.getUniqueId());
+ }
+
@Override public void onDisable() {
+ if (compat != null) compat.stop();
+ if (api != null) {
+ getServer().getServicesManager().unregister(ScopenetApi.class, api);
+ ScopenetApiProvider.unregister(api);
+ api.close();
+ }
if (integration != null) integration.close();
}
@@ -180,6 +212,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void leave(PlayerQuitEvent event) {
deniedAt.remove(event.getPlayer().getUniqueId());
+ if (api != null) api.forget(event.getPlayer().getUniqueId());
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java
index 605b121..800d03a 100644
--- a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java
+++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java
@@ -12,9 +12,12 @@ public final class ScopenetCommandHandler implements CommandExecutor {
private final ScopenetPlugin plugin;
private final Integration integration;
- public ScopenetCommandHandler(ScopenetPlugin plugin, Integration integration) {
+ private final java.util.function.Supplier compat;
+
+ public ScopenetCommandHandler(ScopenetPlugin plugin, Integration integration, java.util.function.Supplier compat) {
this.plugin = plugin;
this.integration = integration;
+ this.compat = compat;
}
@Override
@@ -116,6 +119,22 @@ public final class ScopenetCommandHandler implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "Social: " + formatFlag(s.socialEnabled()));
sender.sendMessage(ChatColor.GRAY + "Essentials: " + formatFlag(s.essentialsEnabled()));
sender.sendMessage(ChatColor.GRAY + "Economy: " + formatFlag(s.economyEnabled()));
+ net.scopenet.paper.compat.CompatManager manager = compat.get();
+ if (manager != null) {
+ sender.sendMessage(ChatColor.GOLD + "=== Plugin Integrations ===");
+ for (net.scopenet.paper.compat.CompatManager.Status status : manager.statuses()) {
+ String mark = switch (status.state()) {
+ case ACTIVE -> ChatColor.GREEN + "ACTIVE";
+ case FAILED -> ChatColor.RED + "FAILED";
+ case DISABLED_IN_CONFIG -> ChatColor.YELLOW + "OFF (config)";
+ case NOT_INSTALLED -> ChatColor.DARK_GRAY + "not installed";
+ };
+ String detail = status.state() == net.scopenet.paper.compat.CompatManager.State.FAILED
+ ? ChatColor.GRAY + " " + status.message() : "";
+ String version = status.version() == null || status.version().isEmpty() ? "" : ChatColor.DARK_GRAY + " v" + status.version();
+ sender.sendMessage(ChatColor.GRAY + status.name() + ": " + mark + version + detail);
+ }
+ }
}
private String formatFlag(boolean enabled) {
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatContext.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatContext.java
new file mode 100644
index 0000000..f8cc59c
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatContext.java
@@ -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);
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatManager.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatManager.java
new file mode 100644
index 0000000..f62e901
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/CompatManager.java
@@ -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..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 details) {}
+
+ /** What we know about an integration before (and without) loading its code. */
+ private record Candidate(String id, String name, String plugin, Supplier factory) {}
+
+ private final JavaPlugin plugin;
+ private final CompatContext context;
+ private final List candidates = new ArrayList<>();
+ private final Map running = new ConcurrentHashMap<>();
+ private final Map 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 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 statuses() {
+ List 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());
+ }
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/CoreProtectModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/CoreProtectModule.java
new file mode 100644
index 0000000..7a41001
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/CoreProtectModule.java
@@ -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 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 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 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;
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/EventBridge.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/EventBridge.java
new file mode 100644
index 0000000..a750329
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/EventBridge.java
@@ -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;
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/IntegrationModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/IntegrationModule.java
new file mode 100644
index 0000000..3839d6a
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/IntegrationModule.java
@@ -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..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 details() {
+ return Map.of();
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/LuckPermsModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/LuckPermsModule.java
new file mode 100644
index 0000000..b75c4f0
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/LuckPermsModule.java
@@ -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 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 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 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 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 strings(JsonObject o, String key) {
+ List out = new ArrayList<>();
+ if (o.has(key) && o.get(key).isJsonArray()) o.getAsJsonArray(key).forEach(e -> out.add(e.getAsString()));
+ return out;
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/PlaceholderApiModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/PlaceholderApiModule.java
new file mode 100644
index 0000000..27b6529
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/PlaceholderApiModule.java
@@ -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 details() {
+ return Map.of("placeholders", ScopenetExpansion.PLACEHOLDERS.size());
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/Reflect.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/Reflect.java
new file mode 100644
index 0000000..5ed034a
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/Reflect.java
@@ -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);
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetApiImpl.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetApiImpl.java
new file mode 100644
index 0000000..76adf87
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetApiImpl.java
@@ -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 cache = new ConcurrentHashMap<>();
+ private final Set 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 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 CompletableFuture async(Callable call) {
+ CompletableFuture 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> 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 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 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> 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> 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 getBalance(UUID uuid) {
+ return async(() -> client.getBalance(uuid, ""));
+ }
+
+ @Override public CompletableFuture 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 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 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> getFriends(UUID uuid) {
+ return async(() -> {
+ List 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 call() throws Exception;
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetEconomy.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetEconomy.java
new file mode 100644
index 0000000..a1b2c6b
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetEconomy.java
@@ -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 cache = new ConcurrentHashMap<>();
+ private final Map inFlight = new ConcurrentHashMap<>();
+ private final Map 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 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; }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetExpansion.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetExpansion.java
new file mode 100644
index 0000000..2810a7c
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/ScopenetExpansion.java
@@ -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_%} 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 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 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";
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/SparkModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/SparkModule.java
new file mode 100644
index 0000000..8344636
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/SparkModule.java
@@ -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 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());
+ }
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/VaultModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/VaultModule.java
new file mode 100644
index 0000000..25d58f7
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/VaultModule.java
@@ -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 details() {
+ return Map.of("unsent_transactions", ledger == null ? 0 : ledger.size());
+ }
+}
diff --git a/integrations/paper/src/main/java/net/scopenet/paper/compat/WorldGuardModule.java b/integrations/paper/src/main/java/net/scopenet/paper/compat/WorldGuardModule.java
new file mode 100644
index 0000000..9123a99
--- /dev/null
+++ b/integrations/paper/src/main/java/net/scopenet/paper/compat/WorldGuardModule.java
@@ -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 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 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 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;
+ }
+}
diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml
index 1bebf99..69b1b23 100644
--- a/integrations/paper/src/main/resources/config.yml
+++ b/integrations/paper/src/main/resources/config.yml
@@ -63,3 +63,33 @@ economy:
# Currency symbol
currency-symbol: "$"
+
+# ==============================================================================
+# Plugin integrations
+# SCOPENET detects these plugins by itself; switch any of them off here.
+# ==============================================================================
+integrations:
+ luckperms:
+ enabled: true
+ # Send each online player's LuckPerms rank to the panel (profiles, admin view).
+ report-interval-seconds: 60
+ # Let the panel add/remove LuckPerms group membership for groups an admin has mapped to a panel
+ # group (Panel > Players > Groups). LuckPerms stays in charge of permissions; this is off unless you opt in.
+ apply-panel-groups: false
+ placeholderapi:
+ # %scopenet_level%, %scopenet_guild%, %scopenet_balance%, %scopenet_playtime%, %scopenet_quest_progress% ...
+ enabled: true
+ vault:
+ # Use the SCOPENET economy as Vault's economy, so other plugins' shops and jobs pay into it.
+ enabled: true
+ coreprotect:
+ enabled: true
+ # Folders scanned for world backups shown in the panel.
+ backup-folders:
+ - backups
+ worldguard:
+ enabled: true
+ report-interval-minutes: 5
+ spark:
+ enabled: true
+ report-interval-seconds: 30
diff --git a/integrations/paper/src/main/resources/plugin.yml b/integrations/paper/src/main/resources/plugin.yml
index d383d72..059519c 100644
--- a/integrations/paper/src/main/resources/plugin.yml
+++ b/integrations/paper/src/main/resources/plugin.yml
@@ -4,6 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP
+softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
commands:
scopenet:
diff --git a/integrations/settings.gradle b/integrations/settings.gradle
index 2bfcc54..973ee14 100644
--- a/integrations/settings.gradle
+++ b/integrations/settings.gradle
@@ -6,7 +6,7 @@ pluginManagement {
}
}
rootProject.name = 'scopenet-integrations'
-include 'common', 'paper'
+include 'common', 'api', 'paper'
// Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'