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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<UUID, Long> 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(),
|
||||
|
||||
@@ -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<SqlitePool> {
|
||||
|
||||
@@ -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::<Vec<_>>() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ClaimIndexPayload {
|
||||
/// The revision the game server already holds.
|
||||
pub revision: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<AppState>,
|
||||
Json(payload): Json<ClaimIndexPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<Value> = Vec::new();
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut claims: Vec<Value> = 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<String, Vec<String>> = 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(
|
||||
|
||||
@@ -175,6 +175,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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<String>| {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user