Claim protection from a local index: revision-polled panel endpoint, no per-block network checks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:51:05 +00:00
1 parent 78da10364f
commit 5defda1128
10 files changed
+442 -127

No files matched your search

@@ -0,0 +1,91 @@
package net.scopenet.integration;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Every claimed chunk on this server, and who may build in each, held in
* memory. Block events ask this instead of the panel, so protection costs a
* couple of hash lookups and keeps working if the panel is briefly away.
* The panel is polled for a new copy only when its revision changes.
*/
public final class ClaimIndex {
private record Guild(String id, String name, String tag) {}
private record Snapshot(String revision, Map<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {}
private static final ChunkCheckResult WILDERNESS = new ChunkCheckResult(false, true, null, null);
private volatile Snapshot snapshot;
static long key(int chunkX, int chunkZ) {
return ((long) chunkX << 32) | (chunkZ & 0xffffffffL);
}
/** False until the first copy arrives from the panel. */
public boolean loaded() { return snapshot != null; }
public String revision() {
Snapshot s = snapshot;
return s == null ? null : s.revision();
}
public int claimCount() {
Snapshot s = snapshot;
return s == null ? 0 : s.total();
}
/** Is this chunk claimed by any guild? Used for environmental changes (fire, pistons, explosions). */
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
Snapshot s = snapshot;
if (s == null) return false;
Map<Long, Guild> dim = s.claims().get(dimension);
return dim != null && dim.containsKey(key(chunkX, chunkZ));
}
/** May {@code player} build here? Unclaimed land always answers yes. */
public ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player) {
Snapshot s = snapshot;
if (s == null) return WILDERNESS;
Map<Long, Guild> dim = s.claims().get(dimension);
Guild guild = dim == null ? null : dim.get(key(chunkX, chunkZ));
if (guild == null) return WILDERNESS;
Set<UUID> members = s.members().get(guild.id());
return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag());
}
/** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */
public boolean apply(JsonObject response) {
if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false;
String revision = response.has("revision") ? response.get("revision").getAsString() : "";
JsonArray guildList = response.getAsJsonArray("guilds");
Guild[] guilds = new Guild[guildList.size()];
for (int i = 0; i < guilds.length; i++) {
JsonObject g = guildList.get(i).getAsJsonObject();
guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString());
}
Map<String, Map<Long, Guild>> claims = new HashMap<>();
JsonArray claimList = response.getAsJsonArray("claims");
for (JsonElement element : claimList) {
JsonArray c = element.getAsJsonArray();
int guild = c.get(3).getAsInt();
if (guild < 0 || guild >= guilds.length) continue;
claims.computeIfAbsent(c.get(0).getAsString(), d -> new HashMap<>()).put(key(c.get(1).getAsInt(), c.get(2).getAsInt()), guilds[guild]);
}
Map<String, Set<UUID>> members = new HashMap<>();
for (Map.Entry<String, JsonElement> entry : response.getAsJsonObject("members").entrySet()) {
Set<UUID> set = new HashSet<>();
for (JsonElement uuid : entry.getValue().getAsJsonArray()) {
try { set.add(UUID.fromString(uuid.getAsString())); } catch (IllegalArgumentException ignored) { /* skip malformed */ }
}
members.put(entry.getKey(), set);
}
snapshot = new Snapshot(revision, claims, members, claimList.size());
return true;
}
}
@@ -155,8 +155,14 @@ public final class Integration implements AutoCloseable {
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
}
public void prefetchClaims(String dimension, int chunkX, int chunkZ, UUID uuid) {
client.prefetchClaims(dimension, chunkX, chunkZ, uuid);
/** Environmental protection: is this chunk claimed by anyone? */
public boolean isClaimed(String dimension, int chunkX, int chunkZ) {
return client.isClaimed(dimension, chunkX, chunkZ);
}
/** Begin keeping the local claim index in sync with the panel. */
public void startClaimSync() {
client.startClaimSync(log);
}
@Override public void close() {
@@ -8,19 +8,63 @@ import java.time.Duration;
public final class PanelClient {
private volatile Settings settings;
private record ChunkKey(String dimension, int x, int z, java.util.UUID uuid) {}
private record Cached(ChunkCheckResult result, long expires) {}
private final java.util.Map<ChunkKey, Cached> claims = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Set<ChunkKey> pendingClaims = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final java.util.Set<ChunkKey> pendingSnapshots = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final java.util.concurrent.ThreadPoolExecutor claimWorker = new java.util.concurrent.ThreadPoolExecutor(
2, 2, 0, java.util.concurrent.TimeUnit.SECONDS, new java.util.concurrent.ArrayBlockingQueue<>(64),
task -> { Thread t = new Thread(task, "scopenet-claims"); t.setDaemon(true); return t; });
private static final ChunkCheckResult UNKNOWN = new ChunkCheckResult(true, false, "Protection check pending or unavailable", "WAIT");
private final java.util.concurrent.atomic.AtomicLong claimGeneration = new java.util.concurrent.atomic.AtomicLong();
public void invalidateClaims() { claimGeneration.incrementAndGet(); claims.clear(); }
public void updateSettings(Settings next) { settings = next; invalidateClaims(); }
public void close() { claimWorker.shutdownNow(); }
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; }
@@ -104,80 +148,14 @@ public final class PanelClient {
? 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);
ChunkKey key = new ChunkKey(dimension, chunkX, chunkZ, uuid);
Cached cached = claims.get(key);
if (cached != null && cached.expires() > System.nanoTime()) return cached.result();
if (pendingClaims.add(key)) {
long generation = claimGeneration.get();
try { claimWorker.execute(() -> {
try {
ChunkCheckResult result = checkChunkRemote(dimension, chunkX, chunkZ, uuid);
if (claims.size() > 4096) claims.clear();
if (generation == claimGeneration.get()) claims.put(key, new Cached(result, System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2)));
} finally { pendingClaims.remove(key); }
}); } catch (java.util.concurrent.RejectedExecutionException e) { pendingClaims.remove(key); }
}
return UNKNOWN;
return claimIndex.check(dimension, chunkX, chunkZ, uuid);
}
/** Warm a 5x5 area when a player changes chunks, including environmental protection. */
public void prefetchClaims(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return;
ChunkKey center = new ChunkKey(dimension, chunkX, chunkZ, uuid);
Cached cached = claims.get(center);
if (cached != null && cached.expires() > System.nanoTime()) return;
if (!pendingSnapshots.add(center)) return;
long generation = claimGeneration.get();
try { claimWorker.execute(() -> {
try {
JsonObject req = new JsonObject();
req.addProperty("uuid", uuid.toString());
req.addProperty("dimension", dimension);
req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ);
JsonObject snapshot = post("guilds/claim-snapshot", req);
java.util.Map<String, ChunkCheckResult> found = new java.util.HashMap<>();
for (JsonElement el : snapshot.getAsJsonArray("claims")) {
JsonObject c = el.getAsJsonObject();
String coord = c.get("chunk_x").getAsInt() + ":" + c.get("chunk_z").getAsInt();
found.put(coord, new ChunkCheckResult(true, c.get("allowed").getAsBoolean(),
c.get("guild_name").getAsString(), c.get("guild_tag").getAsString()));
}
long expiry = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
if (generation != claimGeneration.get()) return;
if (claims.size() > 4096) claims.clear();
for (int x = chunkX - 2; x <= chunkX + 2; x++) for (int z = chunkZ - 2; z <= chunkZ + 2; z++) {
ChunkCheckResult value = found.getOrDefault(x + ":" + z, new ChunkCheckResult(false, true, null, null));
claims.put(new ChunkKey(dimension, x, z, uuid), new Cached(value, expiry));
claims.put(new ChunkKey(dimension, x, z, new java.util.UUID(0, 0)),
new Cached(new ChunkCheckResult(value.claimed(), !value.claimed(), value.guildName(), value.guildTag()), expiry));
}
} catch (Exception ignored) { /* cold checks remain fail closed */ }
finally { pendingSnapshots.remove(center); }
}); } catch (java.util.concurrent.RejectedExecutionException e) { pendingSnapshots.remove(center); }
}
private ChunkCheckResult checkChunkRemote(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) {
return new ChunkCheckResult(false, true, null, null);
}
JsonObject req = new JsonObject();
req.addProperty("dimension", dimension);
req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ);
req.addProperty("uuid", uuid.toString());
try {
JsonObject res = post("guilds/check-chunk", req);
boolean claimed = res.has("claimed") && res.get("claimed").getAsBoolean();
boolean allowed = res.has("allowed") && res.get("allowed").getAsBoolean();
String guildName = res.has("guild_name") && !res.get("guild_name").isJsonNull() ? res.get("guild_name").getAsString() : null;
String guildTag = res.has("guild_tag") && !res.get("guild_tag").isJsonNull() ? res.get("guild_tag").getAsString() : null;
return new ChunkCheckResult(claimed, allowed, guildName, guildTag);
} catch (Exception e) {
return UNKNOWN;
}
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 {
@@ -187,7 +165,7 @@ public final class PanelClient {
req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ);
JsonObject result = post("guilds/claim", req);
invalidateClaims();
requestClaimRefresh();
return result;
}
@@ -198,7 +176,7 @@ public final class PanelClient {
req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ);
JsonObject result = post("guilds/unclaim", req);
invalidateClaims();
requestClaimRefresh();
return result;
}
@@ -214,13 +192,17 @@ public final class PanelClient {
req.addProperty("username", username);
req.addProperty("name", name);
req.addProperty("tag", tag);
return post("guilds/create", req);
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());
return post("guilds/leave", req);
JsonObject result = post("guilds/leave", req);
requestClaimRefresh();
return result;
}
public double getBalance(java.util.UUID uuid, String username) throws IOException, InterruptedException {
@@ -0,0 +1,93 @@
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 java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class ClaimIndexTest {
private static final UUID MEMBER = UUID.fromString("b50ad385-829d-3141-a216-7e7d7539ba7f");
private static final UUID OUTSIDER = UUID.randomUUID();
private static final String INDEX = """
{"revision":"7","unchanged":false,
"guilds":[{"id":"g1","name":"Iron Fortress","tag":"IRON"}],
"claims":[["minecraft:overworld",3,-4,0],["minecraft:the_nether",-1,-1,0]],
"members":{"g1":["b50ad385-829d-3141-a216-7e7d7539ba7f","not-a-uuid"]}}""";
private static JsonObject json(String s) { return JsonParser.parseString(s).getAsJsonObject(); }
@Test void answersLocallyAndTreatsWildernessAsOpen() {
ClaimIndex index = new ClaimIndex();
assertFalse(index.loaded());
assertTrue(index.check("minecraft:overworld", 3, -4, OUTSIDER).allowed(), "nothing is enforced before the first load");
assertTrue(index.apply(json(INDEX)));
assertEquals("7", index.revision());
assertEquals(2, index.claimCount());
ChunkCheckResult mine = index.check("minecraft:overworld", 3, -4, MEMBER);
assertTrue(mine.claimed() && mine.allowed());
ChunkCheckResult theirs = index.check("minecraft:overworld", 3, -4, OUTSIDER);
assertTrue(theirs.claimed() && !theirs.allowed());
assertEquals("IRON", theirs.guildTag());
assertFalse(index.check("minecraft:overworld", 3, -3, OUTSIDER).claimed());
assertFalse(index.check("minecraft:the_end", 3, -4, OUTSIDER).claimed(), "claims are per dimension");
assertTrue(index.isClaimed("minecraft:the_nether", -1, -1));
assertFalse(index.isClaimed("minecraft:overworld", -1, -1));
}
@Test void negativeCoordinatesDoNotCollide() {
ClaimIndex index = new ClaimIndex();
index.apply(json("""
{"revision":"1","guilds":[{"id":"g","name":"A","tag":"A"}],
"claims":[["d",-1,0,0],["d",0,-1,0],["d",-1,-1,0]],"members":{}}"""));
assertTrue(index.isClaimed("d", -1, 0));
assertTrue(index.isClaimed("d", 0, -1));
assertTrue(index.isClaimed("d", -1, -1));
assertFalse(index.isClaimed("d", 0, 0));
assertFalse(index.isClaimed("d", 1, -1));
}
@Test void unchangedResponsesKeepTheCurrentCopy() {
ClaimIndex index = new ClaimIndex();
index.apply(json(INDEX));
assertFalse(index.apply(json("{\"revision\":\"7\",\"unchanged\":true}")));
assertEquals(2, index.claimCount());
}
@Test void pollingSendsTheRevisionAndSurvivesPanelOutages() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
AtomicInteger calls = new AtomicInteger();
StringBuilder lastBody = new StringBuilder();
server.createContext("/api/server/v1/guilds/claim-index", exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
lastBody.setLength(0);
lastBody.append(body);
int n = calls.incrementAndGet();
byte[] out = (n == 1 ? INDEX : n == 2 ? "{\"revision\":\"7\",\"unchanged\":true}" : "boom").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(n >= 3 ? 503 : 200, out.length);
exchange.getResponseBody().write(out);
exchange.close();
});
server.start();
try {
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
assertTrue(client.refreshClaims());
assertEquals("{}", lastBody.toString(), "first poll has no revision");
assertFalse(client.refreshClaims());
assertEquals("{\"revision\":\"7\"}", lastBody.toString());
assertThrows(java.io.IOException.class, client::refreshClaims);
// The last good copy still protects land while the panel is down.
assertFalse(client.checkChunk("minecraft:overworld", 3, -4, OUTSIDER).allowed());
assertTrue(client.checkChunk("minecraft:overworld", 3, -4, MEMBER).allowed());
assertTrue(client.isClaimed("minecraft:overworld", 3, -4));
client.close();
} finally {
server.stop(0);
}
}
}