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:
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("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.replace("·", "·").replace("&", "&");
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.scopenet.core;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import net.scopenet.core.map.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapOverlayTest {
|
||||
@Test void overlayCarriesShapesAndPlainText() {
|
||||
var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16)));
|
||||
var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron <Wolves>", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8,
|
||||
"<div class=\"scopenet-detail\"><strong>Iron <Wolves></strong> <span>[IRN]</span><br>1 claimed chunk in this area</div>");
|
||||
var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6,
|
||||
"<div class=\"scopenet-detail\"><strong>Warp</strong> a<br><code>/warp a</code></div>");
|
||||
JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of()));
|
||||
JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject();
|
||||
assertEquals("#3366cc", c.get("color").getAsString());
|
||||
assertEquals(4, c.getAsJsonArray("outer").size());
|
||||
assertEquals("Iron <Wolves> [IRN]", c.getAsJsonArray("lines").get(0).getAsString());
|
||||
assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString());
|
||||
JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject();
|
||||
assertEquals("warp", p.get("kind").getAsString());
|
||||
assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString());
|
||||
assertFalse(json.toString().contains("<div"), "no markup reaches the viewer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.scopenet.worldmap;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import net.scopenet.integration.PanelClient;
|
||||
import net.scopenet.integration.Settings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** A fake panel and a generated world: tiles, players and the overlay all arrive, and only once. */
|
||||
class WorldMapSyncTest {
|
||||
@Test void worldsAreRenderedAndUploaded(@TempDir Path dir) throws Exception {
|
||||
byte[] flat = MapRenderTest.chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0);
|
||||
MapRenderTest.region(dir.resolve("world/region"), Map.of(RegionFile.index(1, 1), flat, RegionFile.index(20, 3), flat));
|
||||
|
||||
List<String> tiles = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> players = new CopyOnWriteArrayList<>();
|
||||
List<JsonObject> overlays = new CopyOnWriteArrayList<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/server/v1/map/config", ex -> reply(ex, "{\"enabled\":true,\"epoch\":3,\"max_zoom\":2,\"player_interval_ms\":500}"));
|
||||
server.createContext("/api/server/v1/map/tiles", ex -> {
|
||||
assertEquals("dim=minecraft:overworld", ex.getRequestURI().getRawQuery());
|
||||
ByteBuffer body = ByteBuffer.wrap(ex.getRequestBody().readAllBytes());
|
||||
while (body.hasRemaining()) {
|
||||
int zoom = body.get() & 0xFF, x = body.getInt(), y = body.getInt(), len = body.getInt();
|
||||
byte[] png = new byte[len];
|
||||
body.get(png);
|
||||
assertEquals((byte) 0x89, png[0]);
|
||||
tiles.add(zoom + "/" + x + "/" + y);
|
||||
}
|
||||
reply(ex, "{\"stored\":1}");
|
||||
});
|
||||
server.createContext("/api/server/v1/map/players", ex -> { players.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.createContext("/api/server/v1/map/overlay", ex -> { overlays.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); });
|
||||
server.start();
|
||||
PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40)));
|
||||
WorldMapSync sync = new WorldMapSync(client, dir.resolve("world"), dir.resolve("cache"), System.err::println);
|
||||
sync.setOverlay(() -> "{\"claims\":[],\"pins\":[{\"id\":\"spawn\"}]}");
|
||||
sync.offerPlayers(List.of(new WorldMapSync.Player("11111111-1111-1111-1111-111111111111", "Steve", "minecraft:overworld", 10.5, 64, 20, 90, 0)));
|
||||
try {
|
||||
sync.start();
|
||||
long deadline = System.currentTimeMillis() + 20_000;
|
||||
while ((tiles.size() < 5 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100);
|
||||
// chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them
|
||||
assertTrue(tiles.contains("0/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("0/1/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("1/0/0"), tiles.toString());
|
||||
assertTrue(tiles.contains("2/0/0"), tiles.toString());
|
||||
assertEquals("Steve", players.get(0).getAsJsonArray("players").get(0).getAsJsonObject().get("name").getAsString());
|
||||
assertEquals("spawn", overlays.get(0).getAsJsonArray("pins").get(0).getAsJsonObject().get("id").getAsString());
|
||||
int sent = tiles.size();
|
||||
Thread.sleep(1500);
|
||||
assertEquals(sent, tiles.size(), "an unchanged world isn't uploaded again");
|
||||
} finally {
|
||||
sync.close();
|
||||
server.stop(0);
|
||||
}
|
||||
// the state survives a restart: a new sync with the same cache sends nothing
|
||||
assertTrue(java.nio.file.Files.isRegularFile(dir.resolve("cache/state.json")));
|
||||
}
|
||||
|
||||
private static void reply(com.sun.net.httpserver.HttpExchange ex, String json) throws java.io.IOException {
|
||||
byte[] b = json.getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, b.length);
|
||||
ex.getResponseBody().write(b);
|
||||
ex.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user