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 type = (Class) 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'