Map: game servers send tiles, players and overlay; BlueMap and LiveMapSync removed from the Java side

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-10-01 00:43:11 +00:00
1 parent ead4a706c7
commit d98cc61ee6
31 files changed
+268 -596

No files matched your search

@@ -52,7 +52,7 @@ public final class MapModel {
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
}
/** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
/** Which Minecraft dimension a world id or name means ("world_nether" -> the nether). */
public static String dimensionOf(String id, String name) {
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
if (words.contains("nether")) return "minecraft:the_nether";
@@ -0,0 +1,81 @@
package net.scopenet.core.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.Locale;
/**
* The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text
* lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup.
*/
public final class MapOverlay {
private MapOverlay() {}
public static JsonObject toJson(MapData data) {
JsonArray claims = new JsonArray();
for (MapData.ClaimRegion c : data.claims()) {
JsonObject o = new JsonObject();
o.addProperty("id", c.id());
o.addProperty("guild_id", c.guildId());
o.addProperty("name", c.name());
o.addProperty("tag", c.tag());
o.addProperty("icon", c.icon());
o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF));
o.addProperty("dimension", c.dimension());
o.addProperty("chunks", c.chunks());
o.addProperty("label_x", c.labelX());
o.addProperty("label_z", c.labelZ());
o.add("outer", ring(c.outer()));
JsonArray holes = new JsonArray();
for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole));
o.add("holes", holes);
o.add("lines", lines(c.detail()));
claims.add(o);
}
JsonArray pins = new JsonArray();
for (MapData.Pin p : data.pins()) {
JsonObject o = new JsonObject();
o.addProperty("id", p.id());
o.addProperty("kind", p.kind());
o.addProperty("label", p.label());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.add("lines", lines(p.detail()));
pins.add(o);
}
JsonObject root = new JsonObject();
root.add("claims", claims);
root.add("pins", pins);
return root;
}
private static JsonArray ring(ChunkRegions.Ring ring) {
JsonArray out = new JsonArray();
for (ChunkRegions.Point p : ring.points()) {
JsonArray pair = new JsonArray();
pair.add(p.x());
pair.add(p.z());
out.add(pair);
}
return out;
}
/** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */
static JsonArray lines(String html) {
String text = html == null ? "" : html
.replaceAll("(?i)<br\\s*/?>|</div>|</li>|</ul>|<ul[^>]*>", "\n")
.replaceAll("<[^>]*>", "");
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.replace("&middot;", "·").replace("&amp;", "&");
JsonArray out = new JsonArray();
for (String line : text.split("\n")) {
String t = line.trim();
if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t);
}
return out;
}
}
@@ -9,7 +9,7 @@ import net.scopenet.integration.ClaimIndex;
import java.util.*;
/**
* Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every
* Gathers everything the map needs, from any thread. The map overlay asks for a {@link MapData} every
* few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
*/
public final class MapService {
@@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable {
private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed;
private volatile Consumer<JsonArray> notificationHandler;
private volatile LiveMapSync liveMap;
private volatile net.scopenet.worldmap.WorldMapSync map;
private volatile java.util.function.Supplier<String> mapOverlay;
private boolean registered;
private JsonObject pending;
private long nextSync;
@@ -144,17 +145,34 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler;
}
/** Start sending player positions for the in-app BlueMap territory view. */
public void enableLiveMap() {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client);
liveMap.start();
/**
* Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims
* overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles.
*/
public void enableMap(java.nio.file.Path worldRoot, java.nio.file.Path cacheDir) {
if (closed || map != null) return;
map = new net.scopenet.worldmap.WorldMapSync(client, worldRoot, cacheDir, log);
if (mapOverlay != null) map.setOverlay(mapOverlay);
map.start();
}
/** Where the map gets its claims and pins (JSON text); may be called before or after {@link #enableMap}. */
public void setMapOverlay(java.util.function.Supplier<String> overlay) {
mapOverlay = overlay;
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.setOverlay(overlay);
}
/** Hand over a snapshot of player positions; call about once a second on the server thread. */
public void liveMapPlayers(java.util.List<LiveMapSync.Player> players) {
LiveMapSync map = liveMap;
if (map != null) map.offerPlayers(players);
public void mapPlayers(java.util.List<net.scopenet.worldmap.WorldMapSync.Player> players) {
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.offerPlayers(players);
}
/** The world was just saved: pick up its changed regions soon. */
public void mapSaved() {
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.requestScan();
}
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
@@ -173,8 +191,8 @@ public final class Integration implements AutoCloseable {
@Override public void close() {
closed = true;
LiveMapSync map = liveMap;
if (map != null) map.close();
net.scopenet.worldmap.WorldMapSync current = map;
if (current != null) current.close();
client.close();
loginWorker.shutdownNow();
activity.leaveAll();
@@ -1,70 +0,0 @@
package net.scopenet.integration;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private final PanelClient client;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "scopenet-positions");
thread.setDaemon(true);
return thread;
});
private volatile List<Player> players = List.of();
private volatile boolean changed;
private volatile boolean closed;
private long lastSent;
public LiveMapSync(PanelClient client) { this.client = client; }
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
changed = true;
}
}
private void sendSafely() {
if (closed) return;
long now = System.nanoTime();
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject entry = new JsonObject();
entry.addProperty("uuid", p.uuid());
entry.addProperty("name", p.name());
entry.addProperty("dimension", p.dimension());
entry.addProperty("x", p.x());
entry.addProperty("y", p.y());
entry.addProperty("z", p.z());
entry.addProperty("yaw", p.yaw());
entry.addProperty("pitch", p.pitch());
list.add(entry);
}
JsonObject body = new JsonObject();
body.add("players", list);
try {
client.post("livemap/players", body);
changed = false;
lastSent = now;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// Keep the latest snapshot and retry on the next interval.
}
}
@Override public void close() {
closed = true;
worker.shutdownNow();
}
}
@@ -107,12 +107,12 @@ public record Settings(
);
}
/** Local opt-out for the central live map (the panel also has a per-server switch). */
public static boolean liveMapEnabled(Path path) {
/** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
public static boolean mapEnabled(Path path) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
Properties properties = new Properties();
properties.load(reader);
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true")));
} catch (IOException e) {
return true;
}
@@ -139,9 +139,9 @@ public record Settings(
essentials.enabled=true
economy.enabled=true
# Send world chunks and player positions to the panel's central Live Map.
# It only runs when Live Map is also enabled for this server in the panel.
livemap.enabled=true
# Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
# It only runs when the map is also enabled for this server in the panel.
map.enabled=true
""";
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
}
@@ -59,7 +59,7 @@ public final class WorldMapSync implements AutoCloseable {
private volatile int maxZoom = 6;
private volatile long playerIntervalMs = 2000;
private volatile List<Player> players = List.of();
private volatile String overlay;
private volatile java.util.function.Supplier<String> overlay;
private long configAt;
private long coarseUploadAt;
private boolean stateDirty;
@@ -90,9 +90,9 @@ public final class WorldMapSync implements AutoCloseable {
players = List.copyOf(next);
}
/** The claims and pins as JSON ({@code {"claims":[...],"pins":[...]}}); sent when it changes. */
public void offerOverlay(String json) {
overlay = json;
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
public void setOverlay(java.util.function.Supplier<String> source) {
overlay = source;
}
/** The world was saved: look for changed regions soon. */
@@ -337,7 +337,8 @@ public final class WorldMapSync implements AutoCloseable {
lastPlayersSent = text;
playersSentAt = now;
}
String ov = overlay;
java.util.function.Supplier<String> source = overlay;
String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get();
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
lastOverlaySent = ov;