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