Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
284 lines
14 KiB
Java
284 lines
14 KiB
Java
package net.scopenet.integration;
|
|
|
|
import com.google.gson.*;
|
|
import java.io.IOException;
|
|
import java.net.URI;
|
|
import java.net.http.*;
|
|
import java.time.Duration;
|
|
|
|
public final class PanelClient {
|
|
private volatile Settings settings;
|
|
private final ClaimIndex claimIndex = new ClaimIndex();
|
|
private final java.util.concurrent.ScheduledExecutorService claimSync = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> {
|
|
Thread t = new Thread(task, "scopenet-claims"); t.setDaemon(true); return t;
|
|
});
|
|
private final java.util.concurrent.atomic.AtomicBoolean claimRefreshQueued = new java.util.concurrent.atomic.AtomicBoolean();
|
|
private volatile boolean claimSyncStarted;
|
|
private volatile long claimFailureLogged;
|
|
private volatile java.util.function.Consumer<String> claimLog = m -> {};
|
|
|
|
public void updateSettings(Settings next) { settings = next; requestClaimRefresh(); }
|
|
public void close() { claimSync.shutdownNow(); }
|
|
public ClaimIndex claims() { return claimIndex; }
|
|
|
|
/**
|
|
* Keep the local claim index fresh: load it now (so protection is on from
|
|
* the first tick) and then check the panel's revision every few seconds.
|
|
*/
|
|
public void startClaimSync(java.util.function.Consumer<String> log) {
|
|
if (claimSyncStarted) return;
|
|
claimSyncStarted = true;
|
|
claimLog = log;
|
|
claimSync.scheduleWithFixedDelay(this::refreshClaimsQuietly, 0, 5, java.util.concurrent.TimeUnit.SECONDS);
|
|
}
|
|
|
|
/** Pull a fresh index soon, e.g. right after this server changed a claim. */
|
|
public void requestClaimRefresh() {
|
|
if (!claimSyncStarted || !claimRefreshQueued.compareAndSet(false, true)) return;
|
|
try { claimSync.execute(() -> { claimRefreshQueued.set(false); refreshClaimsQuietly(); }); }
|
|
catch (java.util.concurrent.RejectedExecutionException e) { claimRefreshQueued.set(false); }
|
|
}
|
|
|
|
/** One synchronous refresh. Returns true when the index changed. */
|
|
public boolean refreshClaims() throws IOException, InterruptedException {
|
|
JsonObject body = new JsonObject();
|
|
String revision = claimIndex.revision();
|
|
if (revision != null) body.addProperty("revision", revision);
|
|
return claimIndex.apply(post("guilds/claim-index", body));
|
|
}
|
|
|
|
private void refreshClaimsQuietly() {
|
|
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return;
|
|
try {
|
|
boolean first = !claimIndex.loaded();
|
|
boolean changed = refreshClaims();
|
|
if (first && changed) claimLog.accept("SCOPENET claim protection loaded (" + claimIndex.claimCount() + " claimed chunks)");
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
} catch (Exception e) {
|
|
// Keep enforcing the last copy we have; just don't spam the log.
|
|
long now = System.nanoTime();
|
|
if (now - claimFailureLogged > java.util.concurrent.TimeUnit.MINUTES.toNanos(5)) {
|
|
claimFailureLogged = now;
|
|
claimLog.accept("SCOPENET couldn't refresh claims from the panel (" + e.getMessage() + "); using the last known copy");
|
|
}
|
|
}
|
|
}
|
|
|
|
public static class HttpFailure extends IOException {
|
|
public final int status;
|
|
public HttpFailure(int status, String message) { super(message); this.status = status; }
|
|
}
|
|
private final HttpClient client = HttpClient.newBuilder()
|
|
.connectTimeout(Duration.ofSeconds(3))
|
|
.followRedirects(HttpClient.Redirect.NEVER).build();
|
|
|
|
public PanelClient(Settings settings) { this.settings = settings; }
|
|
|
|
public JsonElement postElement(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
|
.timeout(Duration.ofSeconds(5))
|
|
.header("Authorization", "Bearer " + settings.token())
|
|
.header("Content-Type", "application/json")
|
|
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
|
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
String msg = "HTTP " + response.statusCode();
|
|
try {
|
|
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
|
if (err.has("message")) msg = err.get("message").getAsString();
|
|
else if (err.has("error")) msg = err.get("error").getAsString();
|
|
} catch (Exception ignored) {}
|
|
throw new HttpFailure(response.statusCode(), msg);
|
|
}
|
|
try {
|
|
return JsonParser.parseString(response.body());
|
|
} catch (RuntimeException e) {
|
|
throw new IOException("Invalid panel response", e);
|
|
}
|
|
}
|
|
|
|
public HttpClient http() { return client; }
|
|
public Settings settings() { return settings; }
|
|
|
|
private static JsonObject object(HttpResponse<String> response) throws IOException {
|
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
String msg = "HTTP " + response.statusCode();
|
|
try {
|
|
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
|
if (err.has("message")) msg = err.get("message").getAsString();
|
|
else if (err.has("error")) msg = err.get("error").getAsString();
|
|
} catch (Exception ignored) {}
|
|
throw new HttpFailure(response.statusCode(), msg);
|
|
}
|
|
try { return JsonParser.parseString(response.body()).getAsJsonObject(); }
|
|
catch (RuntimeException e) { throw new IOException("Invalid panel response", e); }
|
|
}
|
|
|
|
/** GET an endpoint under /api/server/v1/ that returns a JSON object. */
|
|
public JsonObject get(String endpoint) throws IOException, InterruptedException {
|
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
|
.timeout(Duration.ofSeconds(15))
|
|
.header("Authorization", "Bearer " + settings.token()).GET().build();
|
|
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
|
}
|
|
|
|
/** POST raw bytes (live map chunk batches and level.dat). */
|
|
public JsonObject postBytes(String endpoint, byte[] body) throws IOException, InterruptedException {
|
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
|
.timeout(Duration.ofSeconds(60))
|
|
.header("Authorization", "Bearer " + settings.token())
|
|
.header("Content-Type", "application/octet-stream")
|
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
|
|
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
|
}
|
|
|
|
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
|
return postElement(endpoint, payload).getAsJsonObject();
|
|
}
|
|
|
|
public static String verdict(JsonObject response) throws IOException {
|
|
JsonElement allowed = response.get("allowed");
|
|
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
|
|
throw new IOException("Missing login verdict");
|
|
}
|
|
if (allowed.getAsBoolean()) return null;
|
|
JsonElement message = response.get("message");
|
|
return message != null && message.isJsonPrimitive() && message.getAsJsonPrimitive().isString()
|
|
? message.getAsString() : "You do not have access to this server.";
|
|
}
|
|
|
|
/** Local lookup; never touches the network. */
|
|
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
|
|
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return new ChunkCheckResult(false, true, null, null);
|
|
return claimIndex.check(dimension, chunkX, chunkZ, uuid);
|
|
}
|
|
|
|
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
|
|
return settings.guildsEnabled() && settings.landClaimingEnabled() && claimIndex.isClaimed(dimension, chunkX, chunkZ);
|
|
}
|
|
|
|
public JsonObject claimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("dimension", dimension);
|
|
req.addProperty("chunk_x", chunkX);
|
|
req.addProperty("chunk_z", chunkZ);
|
|
JsonObject result = post("guilds/claim", req);
|
|
requestClaimRefresh();
|
|
return result;
|
|
}
|
|
|
|
public JsonObject unclaimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("dimension", dimension);
|
|
req.addProperty("chunk_x", chunkX);
|
|
req.addProperty("chunk_z", chunkZ);
|
|
JsonObject result = post("guilds/unclaim", req);
|
|
requestClaimRefresh();
|
|
return result;
|
|
}
|
|
|
|
public JsonObject getPlayerGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
return post("guilds/player", req);
|
|
}
|
|
|
|
public JsonObject createGuild(java.util.UUID uuid, String username, String name, String tag) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("username", username);
|
|
req.addProperty("name", name);
|
|
req.addProperty("tag", tag);
|
|
JsonObject result = post("guilds/create", req);
|
|
requestClaimRefresh();
|
|
return result;
|
|
}
|
|
|
|
public JsonObject leaveGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
JsonObject result = post("guilds/leave", req);
|
|
requestClaimRefresh();
|
|
return result;
|
|
}
|
|
|
|
public double getBalance(java.util.UUID uuid, String username) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("username", username);
|
|
JsonObject res = post("economy/balance", req);
|
|
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
|
|
}
|
|
|
|
public double transfer(java.util.UUID fromUuid, String fromName, java.util.UUID toUuid, String toName, double amount, String description) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("from_uuid", fromUuid.toString());
|
|
req.addProperty("from_name", fromName);
|
|
req.addProperty("to_uuid", toUuid.toString());
|
|
req.addProperty("to_name", toName);
|
|
req.addProperty("amount", amount);
|
|
req.addProperty("description", description);
|
|
JsonObject res = post("economy/transfer", req);
|
|
return res.has("from_balance") ? res.get("from_balance").getAsDouble() : 0.0;
|
|
}
|
|
|
|
public void syncBalance(java.util.UUID uuid, String username, double newBalance) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("username", username);
|
|
req.addProperty("new_balance", newBalance);
|
|
post("economy/sync-balance", req);
|
|
}
|
|
|
|
public JsonArray getBaltop() throws IOException, InterruptedException {
|
|
JsonElement el = postElement("economy/baltop", new JsonObject());
|
|
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
|
}
|
|
|
|
public JsonObject marketList(java.util.UUID sellerUuid, String sellerName, String itemId, String itemName, int amount, double price) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
|
req.addProperty("seller_uuid", sellerUuid.toString());
|
|
req.addProperty("seller_name", sellerName);
|
|
req.addProperty("item_id", itemId);
|
|
req.addProperty("item_name", itemName);
|
|
req.addProperty("amount", amount);
|
|
req.addProperty("price", price);
|
|
return post("economy/market/list", req);
|
|
}
|
|
|
|
public JsonObject marketBuy(long listingId, java.util.UUID buyerUuid, String buyerName) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
|
req.addProperty("listing_id", listingId);
|
|
req.addProperty("buyer_uuid", buyerUuid.toString());
|
|
req.addProperty("buyer_name", buyerName);
|
|
return post("economy/market/buy", req);
|
|
}
|
|
|
|
/**
|
|
* Atomically add (or subtract, if amount is negative) to a player's balance.
|
|
* Returns the new balance. This avoids the read-modify-write race in the old
|
|
* getBalance + syncBalance pattern.
|
|
*/
|
|
public double addBalance(java.util.UUID uuid, String username, double amount, String description) throws IOException, InterruptedException {
|
|
JsonObject req = new JsonObject();
|
|
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
|
|
req.addProperty("uuid", uuid.toString());
|
|
req.addProperty("username", username);
|
|
req.addProperty("delta", amount);
|
|
req.addProperty("description", description);
|
|
JsonObject res = post("economy/adjust", req);
|
|
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
|
|
}
|
|
|
|
/** Fetch active player marketplace listings. */
|
|
public JsonArray getMarketListings() throws IOException, InterruptedException {
|
|
JsonElement el = postElement("economy/market", new JsonObject());
|
|
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
|
}
|
|
}
|