From 5defda11280274bc70f7a032da33dd23f5187a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:51:05 +0000 Subject: [PATCH] Claim protection from a local index: revision-polled panel endpoint, no per-block network checks Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- .../net/scopenet/integration/ClaimIndex.java | 91 ++++++++++ .../net/scopenet/integration/Integration.java | 10 +- .../net/scopenet/integration/PanelClient.java | 156 ++++++++---------- .../scopenet/integration/ClaimIndexTest.java | 93 +++++++++++ .../java/net/scopenet/minecraft/Bridge.java | 1 + .../net/scopenet/paper/ScopenetPlugin.java | 73 ++++---- panel/server/src/db.rs | 14 ++ panel/server/src/routes/guilds.rs | 54 +++++- panel/server/src/routes/mod.rs | 1 + panel/server/tests/claim_index.rs | 76 +++++++++ 10 files changed, 442 insertions(+), 127 deletions(-) create mode 100644 integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java create mode 100644 integrations/common/src/test/java/net/scopenet/integration/ClaimIndexTest.java create mode 100644 panel/server/tests/claim_index.rs diff --git a/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java b/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java new file mode 100644 index 0000000..8b4ee1e --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java @@ -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> claims, Map> 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 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 dim = s.claims().get(dimension); + Guild guild = dim == null ? null : dim.get(key(chunkX, chunkZ)); + if (guild == null) return WILDERNESS; + Set 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> 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> members = new HashMap<>(); + for (Map.Entry entry : response.getAsJsonObject("members").entrySet()) { + Set 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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 5120492..86da50c 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -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() { diff --git a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java index b31fb9f..d9a4c9f 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java +++ b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java @@ -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 claims = new java.util.concurrent.ConcurrentHashMap<>(); - private final java.util.Set pendingClaims = java.util.concurrent.ConcurrentHashMap.newKeySet(); - private final java.util.Set 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 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 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 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 { diff --git a/integrations/common/src/test/java/net/scopenet/integration/ClaimIndexTest.java b/integrations/common/src/test/java/net/scopenet/integration/ClaimIndexTest.java new file mode 100644 index 0000000..971fe48 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/integration/ClaimIndexTest.java @@ -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); + } + } +} diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index 31fe54c..e33b288 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -25,6 +25,7 @@ public final class Bridge { ServerPlayer player = server.getPlayerList().getPlayer(id); if (player != null) player.connection.disconnect(Component.literal(message)); })); + integration.startClaimSync(); Path config = Path.of("config/scopenet.properties"); if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder()); } catch (Exception e) { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index c9a48b3..2b32286 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -36,6 +36,8 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { }); }); Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1); + // Claim protection runs from a local copy of the panel's claims, refreshed in the background. + integration.startClaimSync(); // Central Live Map: mirror chunks and player positions to the panel. if (getConfig().getBoolean("livemap.enabled", true)) { @@ -171,22 +173,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { public void join(PlayerJoinEvent event) { if (integration != null) { integration.activity.join(event.getPlayer().getUniqueId(), event.getPlayer().getName()); - Chunk chunk = event.getPlayer().getLocation().getChunk(); - integration.prefetchClaims(dimension(chunk.getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); } } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void move(PlayerMoveEvent event) { - if (integration == null || event.getTo() == null) return; - org.bukkit.Location from = event.getFrom(), to = event.getTo(); - if (from.getWorld() == to.getWorld() && (from.getBlockX() >> 4) == (to.getBlockX() >> 4) - && (from.getBlockZ() >> 4) == (to.getBlockZ() >> 4)) return; - integration.prefetchClaims(dimension(to.getWorld()), to.getBlockX() >> 4, to.getBlockZ() >> 4, event.getPlayer().getUniqueId()); - } - @EventHandler(priority = EventPriority.MONITOR) public void leave(PlayerQuitEvent event) { + deniedAt.remove(event.getPlayer().getUniqueId()); if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName()); } @@ -200,33 +192,45 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { return integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled(); } + private final java.util.Map deniedAt = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Would {@code player} be stopped from changing {@code block}? Answers from the + * local claim index (no network, and block.getX() >> 4 rather than getChunk() + * so a check never loads a chunk). Staff with scopenet.claims.bypass are exempt. + */ + private ChunkCheckResult denial(Player player, Block block) { + if (!claimsEnabled() || player.hasPermission("scopenet.claims.bypass")) return null; + ChunkCheckResult check = integration.checkChunk(dimension(block.getWorld()), block.getX() >> 4, block.getZ() >> 4, player.getUniqueId()); + return check.claimed() && !check.allowed() ? check : null; + } + + /** One "claimed" notice per second per player, however many events fire. */ + private void tell(Player player, ChunkCheckResult check) { + long now = System.currentTimeMillis(); + Long last = deniedAt.get(player.getUniqueId()); + if (last != null && now - last < 1000) return; + deniedAt.put(player.getUniqueId(), now); + player.sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); + } + private boolean protectedFrom(Player player, Block block) { - if (!claimsEnabled()) return false; - Chunk chunk = block.getChunk(); - ChunkCheckResult check = integration.checkChunk(dimension(block.getWorld()), chunk.getX(), chunk.getZ(), player.getUniqueId()); - if (check.allowed()) return false; - player.sendMessage(ChatColor.RED + "This chunk is protected by a guild or the claim check is unavailable."); + ChunkCheckResult check = denial(player, block); + if (check == null) return false; + tell(player, check); return true; } + /** Environmental changes (fire, fluids, pistons, explosions) never touch claimed land. */ private boolean claimed(Block block) { - if (!claimsEnabled()) return false; - Chunk chunk = block.getChunk(); - // A non-member identity protects environmental changes, including - // while the panel is unavailable and the lookup is pending. - return integration.checkChunk(dimension(block.getWorld()), chunk.getX(), chunk.getZ(), new UUID(0, 0)).claimed(); + return claimsEnabled() && integration.isClaimed(dimension(block.getWorld()), block.getX() >> 4, block.getZ() >> 4); } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void broken(BlockBreakEvent event) { - if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { - Chunk chunk = event.getBlock().getChunk(); - ChunkCheckResult check = integration.checkChunk(dimension(event.getBlock().getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); - if (check.claimed() && !check.allowed()) { - event.setCancelled(true); - event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); - return; - } + if (protectedFrom(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + return; } add(event.getPlayer(), "blocks_broken"); if (integration != null) integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(), @@ -235,14 +239,9 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void placed(BlockPlaceEvent event) { - if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { - Chunk chunk = event.getBlock().getChunk(); - ChunkCheckResult check = integration.checkChunk(dimension(event.getBlock().getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); - if (check.claimed() && !check.allowed()) { - event.setCancelled(true); - event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); - return; - } + if (protectedFrom(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + return; } add(event.getPlayer(), "blocks_placed"); if (integration != null) integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(), diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 51db138..c4f6329 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -570,6 +570,20 @@ const MIGRATIONS: &[&str] = &[ ); CREATE INDEX quest_assignments_user ON quest_assignments(user_uuid, period_key, position); "#, + // Bumped by any change to claims, guilds or membership so game servers can + // poll a single number instead of asking about every block. + r#" + INSERT OR IGNORE INTO kv (key, value) VALUES ('guild_rev', '1'); + CREATE TRIGGER guild_claims_rev_i AFTER INSERT ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guild_claims_rev_d AFTER DELETE ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guild_claims_rev_u AFTER UPDATE ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guild_members_rev_i AFTER INSERT ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guild_members_rev_d AFTER DELETE ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guild_members_rev_u AFTER UPDATE ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guilds_rev_i AFTER INSERT ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guilds_rev_d AFTER DELETE ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + CREATE TRIGGER guilds_rev_u AFTER UPDATE OF name, tag, leader_uuid ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index dcc780a..cc35911 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -8,7 +8,7 @@ use axum::extract::{Path, Query, State}; use axum::Json; use scopenet_shared::{Guild, GuildClaim, GuildMember, GuildPost}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; #[derive(Deserialize)] pub struct InstanceQuery { @@ -908,6 +908,58 @@ pub async fn server_claim_snapshot( })).collect::>() }))) } +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct ClaimIndexPayload { + /// The revision the game server already holds. + pub revision: Option, +} + +/// Everything a game server needs to decide claim protection locally: +/// every claimed chunk on this server plus who belongs to each claiming guild. +/// Answers `{"unchanged": true}` when `revision` is still current, so polling +/// is one cheap query. +pub async fn server_claim_index( + GameServer(server): GameServer, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let revision: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into()); + if payload.revision.as_deref() == Some(revision.as_str()) { + return Ok(Json(json!({ "revision": revision, "unchanged": true }))); + } + let rows: Vec<(String, i32, i32, String, String, String)> = sqlx::query_as( + "SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag + FROM guild_claims gc JOIN guilds g ON g.id = gc.guild_id WHERE gc.server_id = ?", + ) + .bind(server.id) + .fetch_all(&state.db) + .await?; + let members: Vec<(String, String)> = sqlx::query_as( + "SELECT gm.guild_id, gm.uuid FROM guild_members gm + WHERE gm.guild_id IN (SELECT DISTINCT guild_id FROM guild_claims WHERE server_id = ?)", + ) + .bind(server.id) + .fetch_all(&state.db) + .await?; + // Guilds are sent once; each claim refers to its guild by index. + let mut guilds: Vec = Vec::new(); + let mut index: std::collections::HashMap = std::collections::HashMap::new(); + let mut claims: Vec = Vec::with_capacity(rows.len()); + for (dimension, x, z, guild_id, name, tag) in rows { + let i = *index.entry(guild_id.clone()).or_insert_with(|| { + guilds.push(json!({ "id": guild_id, "name": name, "tag": tag })); + guilds.len() - 1 + }); + claims.push(json!([dimension, x, z, i])); + } + let mut roster: std::collections::BTreeMap> = Default::default(); + for (guild, uuid) in members { + roster.entry(guild).or_default().push(uuid); + } + Ok(Json(json!({ "revision": revision, "unchanged": false, "guilds": guilds, "claims": claims, "members": roster }))) +} + /// Token-authenticated check called by the Minecraft server plugin/mod to /// verify if a player can build/break in a chunk. pub async fn server_check_chunk( diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 3054b3f..22c25f5 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -175,6 +175,7 @@ pub fn api(state: &AppState) -> Router { .route("/sync", post(servers::sync)) .route("/guilds/check-chunk", post(guilds::server_check_chunk)) .route("/guilds/claim-snapshot", post(guilds::server_claim_snapshot)) + .route("/guilds/claim-index", post(guilds::server_claim_index)) .route("/guilds/claim", post(guilds::server_claim_chunk)) .route("/guilds/unclaim", post(guilds::server_unclaim_chunk)) .route("/guilds/player", post(guilds::server_get_player_guild)) diff --git a/panel/server/tests/claim_index.rs b/panel/server/tests/claim_index.rs new file mode 100644 index 0000000..beec243 --- /dev/null +++ b/panel/server/tests/claim_index.rs @@ -0,0 +1,76 @@ +//! The claim index game servers poll instead of asking about every block. + +mod common; +use common::*; + +#[tokio::test] +async fn claim_index_follows_claims_and_membership() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let mut tokens = Vec::new(); + for n in ["Alex", "Steve"] { + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await; + tokens.push(t.login(n, "password123").await); + } + let alex_uuid = t.uuid("Alex").await; + let steve_uuid = t.uuid("Steve").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + let sid = srv["server"]["id"].as_i64().unwrap(); + let token = srv["token"].as_str().unwrap().to_string(); + let index = |rev: Option| { + let (t, token) = (&t, token.clone()); + async move { t.call("POST", "/api/server/v1/guilds/claim-index", Some(&token), Some(json!({ "revision": rev }))).await } + }; + + let (s, empty) = index(None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(empty["unchanged"], false); + assert!(empty["claims"].as_array().unwrap().is_empty()); + let rev0 = empty["revision"].as_str().unwrap().to_string(); + let (_, same) = index(Some(rev0.clone())).await; + assert_eq!(same["unchanged"], true); + assert!(same.get("claims").is_none()); + + // A guild, then a claim: the revision moves and the index lists the chunk. + let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await; + let gid = g["id"].as_str().unwrap().to_string(); + let (_, r1) = index(Some(rev0.clone())).await; + assert_eq!(r1["unchanged"], false, "guild creation bumps the revision"); + let claim = |x: i32| { + let (t, tok, gid) = (&t, tokens[0].clone(), gid.clone()); + async move { t.call("POST", &format!("/api/v1/guilds/{gid}/claim"), Some(&tok), Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4}))).await } + }; + let (s, c1) = claim(3).await; + assert_eq!(s, StatusCode::OK, "{c1}"); + claim(4).await; + let (_, idx) = index(Some(r1["revision"].as_str().unwrap().to_string())).await; + assert_eq!(idx["unchanged"], false); + assert_eq!(idx["claims"].as_array().unwrap().len(), 2); + assert_eq!(idx["claims"][0], json!(["minecraft:overworld", 3, -4, 0])); + assert_eq!(idx["guilds"][0]["tag"], "IRON"); + assert_eq!(idx["members"][&gid], json!([alex_uuid])); + let rev2 = idx["revision"].as_str().unwrap().to_string(); + assert_eq!(index(Some(rev2.clone())).await.1["unchanged"], true); + + // Membership changes bump it too. + sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap(); + let (_, idx) = index(Some(rev2.clone())).await; + assert_eq!(idx["unchanged"], false); + assert_eq!(idx["members"][&gid].as_array().unwrap().len(), 2); + let rev3 = idx["revision"].as_str().unwrap().to_string(); + + // Unclaim, and deleting the guild (cascades the claims away). + let cid = c1["id"].as_i64().unwrap(); + t.call("DELETE", &format!("/api/v1/guilds/claims/{cid}"), Some(&tokens[0]), None).await; + let (_, idx) = index(Some(rev3)).await; + assert_eq!(idx["claims"].as_array().unwrap().len(), 1); + let rev4 = idx["revision"].as_str().unwrap().to_string(); + sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&gid).execute(&t.db).await.unwrap(); + let (_, idx) = index(Some(rev4)).await; + assert_eq!(idx["unchanged"], false, "cascaded deletes still fire the revision triggers"); + assert!(idx["claims"].as_array().unwrap().is_empty()); + + // Unauthenticated callers get nothing. + let (s, _) = t.call("POST", "/api/server/v1/guilds/claim-index", None, Some(json!({}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +}