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
+267 -595

No files matched your search

-16
View File
@@ -1,16 +0,0 @@
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
@@ -1,211 +0,0 @@
package net.scopenet.bluemap;
import com.flowpowered.math.vector.Vector2d;
import com.flowpowered.math.vector.Vector3d;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.BlueMapAPI;
import de.bluecolored.bluemap.api.BlueMapMap;
import de.bluecolored.bluemap.api.markers.Marker;
import de.bluecolored.bluemap.api.markers.MarkerSet;
import de.bluecolored.bluemap.api.markers.POIMarker;
import de.bluecolored.bluemap.api.markers.ShapeMarker;
import de.bluecolored.bluemap.api.math.Color;
import de.bluecolored.bluemap.api.math.Shape;
import net.scopenet.core.map.ChunkRegions;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapModel;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
/**
* Puts SCOPENET on the BlueMap 3D map:
* <ul>
* <li>guild land as coloured regions, with the guild's icon and name,</li>
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
* <li>markets and shops as pins that list what is for sale,</li>
* <li>a script that opens a side panel when you click a player.</li>
* </ul>
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
* nothing if BlueMap isn't installed.
*/
public final class MapAddon {
/** Where the addon gets its data. Both calls must be safe from any thread. */
public interface Feed {
MapData snapshot();
JsonObject playersJson();
}
static final String WEB_DIR = "assets/scopenet";
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
static final float CLAIM_Y = 66f;
private final Feed feed;
private final Logger log;
private ScheduledExecutorService worker;
private volatile boolean enabled;
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
/** Hook into BlueMap. Returns a handle to stop it. */
public static MapAddon start(Feed feed, Logger log) {
MapAddon addon = new MapAddon(feed, log);
BlueMapAPI.onEnable(addon::enable);
BlueMapAPI.onDisable(api -> addon.disable());
return addon;
}
public void stop() { disable(); }
// ---- lifecycle -------------------------------------------------------------------------
private synchronized void enable(BlueMapAPI api) {
try {
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
Files.createDirectories(root);
for (String name : ASSETS) copy(name, root.resolve(name));
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
} catch (IOException | RuntimeException e) {
log.warning("SCOPENET could not add its files to BlueMap: " + e);
return;
}
enabled = true;
worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-bluemap");
t.setDaemon(true);
return t;
});
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
}
private synchronized void disable() {
enabled = false;
if (worker != null) worker.shutdownNow();
worker = null;
}
private static void copy(String name, Path target) throws IOException {
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
if (in == null) throw new IOException("missing resource " + name);
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// ---- refresh ---------------------------------------------------------------------------
private void refresh(BlueMapAPI api) {
if (!enabled) return;
try {
MapData data = feed.snapshot();
writePlayers(api);
for (BlueMapMap map : api.getMaps()) update(map, data);
} catch (RuntimeException | IOException e) {
log.fine("SCOPENET map refresh failed: " + e);
}
}
private void writePlayers(BlueMapAPI api) throws IOException {
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
Path tmp = file.resolveSibling("players.json.tmp");
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
private void update(BlueMapMap map, MapData data) {
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
}
/** Replace a marker set's contents in place, so the map doesn't flicker. */
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
wanted.forEach(set::put);
}
// ---- markers ---------------------------------------------------------------------------
private static Shape shape(ChunkRegions.Ring ring) {
List<Vector2d> points = new ArrayList<>();
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
return new Shape(points);
}
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
ShapeMarker marker = ShapeMarker.builder()
.label(c.name())
.shape(shape(c.outer()), CLAIM_Y)
.holes(holes)
.lineColor(new Color(c.color(), 0.95f))
.fillColor(new Color(c.color(), 0.28f))
.lineWidth(3)
.depthTestEnabled(false)
.detail(c.detail())
.build();
out.put("claim-" + c.id(), marker);
}
return out;
}
/** One icon and name per area, in the middle of it, fading out when you are far away. */
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
POIMarker.Builder builder = POIMarker.builder()
.label(c.name() + " [" + c.tag() + "]")
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
.styleClasses("scopenet-guild-icon")
.maxDistance(1600)
.detail(c.detail());
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
out.put("guild-" + c.id(), builder.build());
}
return out;
}
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.Pin p : data.pins()) {
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
if (!p.dimension().equals(dimension) || isShop != shops) continue;
String icon = switch (p.kind()) {
case "spawn" -> "spawn.svg";
case "warp" -> "warp.svg";
case "home" -> "home.svg";
case "guild_home" -> "guild-home.svg";
case "market" -> "market.svg";
default -> "shop.svg";
};
out.put(p.id(), POIMarker.builder()
.label(p.label())
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
.icon(WEB_DIR + "/" + icon, 16, 16)
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
.maxDistance(2400)
.detail(p.detail())
.build());
}
return out;
}
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 243 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 3l11 4v8c0 7-4.6 12-11 14C9.6 27 5 22 5 15V7z" fill="#8b6cff" stroke="#fff" stroke-width="2"/><path d="M16 9l2.2 4.6 5 .7-3.6 3.5.9 5L16 20.4 11.5 22.8l.9-5-3.6-3.5 5-.7z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 288 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fbbf24" stroke="#ffffff" stroke-width="2"/><path d="M16 7l9 8h-3v8h-4v-5h-4v5h-4v-8H7z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 229 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fb7185" stroke="#ffffff" stroke-width="2"/><path d="M7 12l2-5h14l2 5v2c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3zM9 19h14v6H9z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

@@ -1,64 +0,0 @@
/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */
/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */
.scopenet-guild-icon img,
.bm-marker-poi.scopenet-guild-icon img {
width: 34px !important;
height: 34px !important;
object-fit: cover;
border-radius: 9px;
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
background: #1a1d25;
}
.scopenet-pin img,
.bm-marker-poi.scopenet-pin img {
width: 26px !important;
height: 26px !important;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
}
/* Popup text inside marker details. */
.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; }
.scopenet-detail span { opacity: 0.7; }
.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; }
.scopenet-list { margin: 4px 0 0; padding-left: 16px; }
/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */
#scopenet-panel {
position: fixed;
top: 64px;
right: 14px;
width: 300px;
max-height: calc(100vh - 90px);
overflow-y: auto;
z-index: 50;
padding: 16px;
border-radius: 14px;
background: rgba(20, 22, 30, 0.96);
color: #e6e8ee;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 13px;
animation: scopenet-in 0.18s ease;
}
@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } }
#scopenet-panel button.close {
position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px;
background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer;
}
#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; }
#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; }
#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; }
#scopenet-panel .title { color: #fbbf24; font-size: 12px; }
#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; }
#scopenet-panel .chip.lv { color: #8b6cff; }
#scopenet-panel .chip.guild { color: #22d3ee; }
#scopenet-panel .chip.rank { color: #fbbf24; }
#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; }
#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; }
#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; }
@@ -1,115 +0,0 @@
/*
* SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and
* inside the SCOPENET launcher.
*
* - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the
* server keeps up to date next to this file.
* - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel
* with the actions that need a signed-in account: teleport request, message, guild invite.
*/
(function () {
'use strict';
var BASE = 'assets/scopenet/';
var PLAYER_PREFIX = 'bm-marker-bm-player-';
var embedded = window.parent && window.parent !== window;
var players = {};
var selected = null;
function esc(text) {
return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-<uuid>". */
function uuidFromId(id) {
return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null;
}
/** Which player marker is under this screen point? Works even where the marker ignores pointer events. */
function playerAt(x, y) {
var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]');
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id);
}
return null;
}
function hours(seconds) {
seconds = Number(seconds) || 0;
return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm';
}
function panel() {
var el = document.getElementById('scopenet-panel');
if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); }
return el;
}
function render() {
var el = document.getElementById('scopenet-panel');
if (!selected) { if (el) el.remove(); return; }
var p = players[selected] || players[selected.toLowerCase()] || {};
el = panel();
var name = p.name || 'Player';
el.innerHTML =
'<button class="close" aria-label="Close">×</button>' +
'<div class="head"><img alt="" src="assets/playerheads/' + esc(selected) + '.png" onerror="this.src=\'assets/steve.png\'">' +
'<div><h3>' + esc(name) + '</h3>' + (p.title ? '<div class="title">' + esc(p.title) + '</div>' : '') + '</div></div>' +
'<div class="chips">' +
'<span class="chip lv">Level ' + esc(p.level || 1) + '</span>' +
(p.guild ? '<span class="chip guild">[' + esc(p.guildTag) + '] ' + esc(p.guild) + '</span>' : '') +
(p.rank ? '<span class="chip rank">' + esc(p.rank) + '</span>' : '') + '</div>' +
'<div class="facts">' +
'<div>Playtime<strong>' + esc(hours(p.playtime)) + '</strong></div>' +
'<div>Server level<strong>' + esc(p.serverLevel || 1) + '</strong></div>' +
(p.guildRole ? '<div>Guild role<strong>' + esc(p.guildRole) + '</strong></div>' : '') + '</div>' +
'<p class="note">Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.</p>';
el.querySelector('.close').addEventListener('click', function () { select(null); });
}
function select(uuid) {
selected = uuid;
if (embedded) {
window.parent.postMessage(uuid
? { source: 'scopenet-map', type: 'player', uuid: uuid, name: (players[uuid] || {}).name || '' }
: { source: 'scopenet-map', type: 'deselect' }, '*');
} else {
render();
}
}
function refresh() {
fetch(BASE + 'players.json?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j && j.players) { players = j.players; if (!embedded && selected) render(); } })
.catch(function () { /* the file appears once the server has written it */ });
}
// A click is a press and release in nearly the same place; a drag moves the map.
var down = null;
document.addEventListener('pointerdown', function (e) { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true);
document.addEventListener('pointerup', function (e) {
var start = down;
down = null;
if (!start || e.button > 0) return;
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6 || Date.now() - start.t > 700) return;
if (e.target && e.target.closest && e.target.closest('#scopenet-panel')) return;
var uuid = playerAt(e.clientX, e.clientY);
if (uuid) select(uuid);
else if (selected) select(null);
}, true);
window.addEventListener('message', function (e) {
if (e.source === window.parent && e.data && e.data.source === 'scopenet-launcher' && e.data.type === 'hello') { /* acknowledged */ }
});
refresh();
setInterval(refresh, 10000);
if (embedded) window.parent.postMessage({ source: 'scopenet-map', type: 'ready' }, '*');
// Exposed for tests.
window.__scopenet = { uuidFromId: uuidFromId, esc: esc, hours: hours };
})();
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#f97316" stroke="#ffffff" stroke-width="2"/><path d="M8 10h3l2 10h10l2-7H12" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/><circle cx="14" cy="24" r="1.8" fill="#fff"/><circle cx="22" cy="24" r="1.8" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 387 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#34d399" stroke="#ffffff" stroke-width="2"/><path d="M16 7l2.6 6.4 6.9.5-5.3 4.5 1.7 6.7L16 21.3 10.1 25l1.7-6.7-5.3-4.5 6.9-.5z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 270 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#8b6cff" stroke="#ffffff" stroke-width="2"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="2" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

@@ -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); 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) { 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]+")); 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"; 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.*; 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. * few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel.
*/ */
public final class MapService { public final class MapService {
@@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable {
private final AtomicBoolean syncing = new AtomicBoolean(); private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed; private volatile boolean closed;
private volatile Consumer<JsonArray> notificationHandler; 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 boolean registered;
private JsonObject pending; private JsonObject pending;
private long nextSync; private long nextSync;
@@ -144,17 +145,34 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler; this.notificationHandler = handler;
} }
/** Start sending player positions for the in-app BlueMap territory view. */ /**
public void enableLiveMap() { * Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims
if (closed || liveMap != null) return; * overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles.
liveMap = new LiveMapSync(client); */
liveMap.start(); 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. */ /** 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) { public void mapPlayers(java.util.List<net.scopenet.worldmap.WorldMapSync.Player> players) {
LiveMapSync map = liveMap; net.scopenet.worldmap.WorldMapSync current = map;
if (map != null) map.offerPlayers(players); 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) { 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() { @Override public void close() {
closed = true; closed = true;
LiveMapSync map = liveMap; net.scopenet.worldmap.WorldMapSync current = map;
if (map != null) map.close(); if (current != null) current.close();
client.close(); client.close();
loginWorker.shutdownNow(); loginWorker.shutdownNow();
activity.leaveAll(); 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). */ /** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */
public static boolean liveMapEnabled(Path path) { public static boolean mapEnabled(Path path) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
Properties properties = new Properties(); Properties properties = new Properties();
properties.load(reader); 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) { } catch (IOException e) {
return true; return true;
} }
@@ -139,9 +139,9 @@ public record Settings(
essentials.enabled=true essentials.enabled=true
economy.enabled=true economy.enabled=true
# Send world chunks and player positions to the panel's central Live Map. # Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel.
# It only runs when Live Map is also enabled for this server in the panel. # It only runs when the map is also enabled for this server in the panel.
livemap.enabled=true map.enabled=true
"""; """;
Files.writeString(path, defaultProps, StandardCharsets.UTF_8); Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
} }
@@ -59,7 +59,7 @@ public final class WorldMapSync implements AutoCloseable {
private volatile int maxZoom = 6; private volatile int maxZoom = 6;
private volatile long playerIntervalMs = 2000; private volatile long playerIntervalMs = 2000;
private volatile List<Player> players = List.of(); private volatile List<Player> players = List.of();
private volatile String overlay; private volatile java.util.function.Supplier<String> overlay;
private long configAt; private long configAt;
private long coarseUploadAt; private long coarseUploadAt;
private boolean stateDirty; private boolean stateDirty;
@@ -90,9 +90,9 @@ public final class WorldMapSync implements AutoCloseable {
players = List.copyOf(next); players = List.copyOf(next);
} }
/** The claims and pins as JSON ({@code {"claims":[...],"pins":[...]}}); sent when it changes. */ /** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
public void offerOverlay(String json) { public void setOverlay(java.util.function.Supplier<String> source) {
overlay = json; overlay = source;
} }
/** The world was saved: look for changed regions soon. */ /** The world was saved: look for changed regions soon. */
@@ -337,7 +337,8 @@ public final class WorldMapSync implements AutoCloseable {
lastPlayersSent = text; lastPlayersSent = text;
playersSentAt = now; 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) { if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject()); client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
lastOverlaySent = ov; 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 &lt;Wolves&gt;</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();
}
}
-2
View File
@@ -16,7 +16,6 @@ dependencies {
implementation project(':common') implementation project(':common')
if (features) { if (features) {
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1' modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
implementation project(':bluemap')
// Optional at runtime: present only when the LuckPerms mod is installed. // Optional at runtime: present only when the LuckPerms mod is installed.
compileOnly 'net.luckperms:api:5.4' compileOnly 'net.luckperms:api:5.4'
} }
@@ -32,6 +31,5 @@ processResources {
jar { jar {
archiveBaseName = "scopenet-fabric-${mc}" archiveBaseName = "scopenet-fabric-${mc}"
from project(':common').sourceSets.main.output from project(':common').sourceSets.main.output
if (features) from project(':bluemap').sourceSets.main.output
} }
base.archivesName = "scopenet-fabric-${mc}" base.archivesName = "scopenet-fabric-${mc}"
@@ -7,5 +7,5 @@
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] }, "entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"], "mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }, "depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" } "suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
} }
@@ -1,28 +0,0 @@
package net.scopenet.fabric.features;
import net.scopenet.bluemap.MapAddon;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapService;
import java.util.logging.Logger;
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
final class BlueMapHook {
private BlueMapHook() {}
/**
* Registered while the mod initialises (BlueMap reads its script list when it starts), so the data comes from
* {@code service}, which only exists once the server is running: until then the map is simply empty.
*/
static Object start(java.util.function.Supplier<MapService> service, Logger log) {
return MapAddon.start(new MapAddon.Feed() {
@Override public MapData snapshot() { MapService s = service.get(); return s == null ? MapData.empty() : s.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() {
MapService s = service.get();
return s == null ? net.scopenet.core.map.MapModel.playersJson("", java.util.List.of(), System.currentTimeMillis()) : s.playersJson();
}
}, log);
}
static void stop(Object handle) { ((MapAddon) handle).stop(); }
}
@@ -56,17 +56,12 @@ public final class FabricFeatures implements Runnable {
private FabricConfig config; private FabricConfig config;
private ActionPoller poller; private ActionPoller poller;
private MapService map; private MapService map;
private Object blueMap;
private int ticks; private int ticks;
private String placeholderStatus = "off"; private String placeholderStatus = "off";
CommandSet commandSet() { return commands; } CommandSet commandSet() { return commands; }
@Override public void run() { @Override public void run() {
// Hook into BlueMap early: it reads the list of web scripts when it starts.
if (FabricConfig.load().bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) {
try { blueMap = BlueMapHook.start(() -> map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); }
}
ServerLifecycleEvents.SERVER_STARTED.register(this::started); ServerLifecycleEvents.SERVER_STARTED.register(this::started);
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping()); ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
ServerTickEvents.END_SERVER_TICK.register(s -> tick()); ServerTickEvents.END_SERVER_TICK.register(s -> tick());
@@ -116,10 +111,11 @@ public final class FabricFeatures implements Runnable {
integration.onNotifications(events -> link.onPanelEvents(events)); integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass"); Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
// Actions from the map, and the BlueMap addon (claims, pins, player panel) when BlueMap is installed. // Actions from the map, and the claims and pins the SCOPENET Map draws.
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()})); poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(), map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
config.bool("bluemap.show_homes", false), s.getMotd()); config.bool("map.show_homes", false), s.getMotd());
integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); });
registerCommands(s.getCommands().getDispatcher()); registerCommands(s.getCommands().getDispatcher());
registerPlaceholders(); registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r))); ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
@@ -130,7 +126,6 @@ public final class FabricFeatures implements Runnable {
private void stopping() { private void stopping() {
ScopenetEconomy.uninstall(); ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false; Bridge.claimBypass = p -> false;
if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; }
if (platform != null) platform.shutdown(); if (platform != null) platform.shutdown();
commands = null; commands = null;
map = null; map = null;
@@ -33,7 +33,7 @@ public final class Bridge {
})); }));
integration.startClaimSync(); integration.startClaimSync();
Path config = Path.of("config/scopenet.properties"); Path config = Path.of("config/scopenet.properties");
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(); if (Settings.mapEnabled(config)) integration.enableMap(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT), Path.of("config/scopenet/map"));
} catch (Exception e) { } catch (Exception e) {
// Throwing aborts startup; silently disabling an auth mod is unsafe. // Throwing aborts startup; silently disabling an auth mod is unsafe.
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e); throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
@@ -71,13 +71,13 @@ public final class Bridge {
current.tick(); current.tick();
if (++mapTicks >= 20) { if (++mapTicks >= 20) {
mapTicks = 0; mapTicks = 0;
List<LiveMapSync.Player> positions = new ArrayList<>(); List<net.scopenet.worldmap.WorldMapSync.Player> positions = new ArrayList<>();
for (ServerPlayer player : server.getPlayerList().getPlayers()) { for (ServerPlayer player : server.getPlayerList().getPlayers()) {
positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player), positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player),
Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0, Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0,
Math.round(player.getYRot()), Math.round(player.getXRot()))); Math.round(player.getYRot()), Math.round(player.getXRot())));
} }
current.liveMapPlayers(positions); current.mapPlayers(positions);
} }
} }
+1 -3
View File
@@ -6,7 +6,6 @@ repositories {
dependencies { dependencies {
implementation project(':common') implementation project(':common')
implementation project(':api') implementation project(':api')
implementation project(':bluemap')
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT' compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
// Optional integrations: the plugin detects these at runtime and works without them. // Optional integrations: the plugin detects these at runtime and works without them.
@@ -23,7 +22,6 @@ jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from project(':common').sourceSets.main.output from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output from project(':api').sourceSets.main.output
from project(':bluemap').sourceSets.main.output from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') }.collect { zipTree(it) } }
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA' exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
} }
@@ -31,7 +31,6 @@ final class ClientLinkBridge implements PluginMessageListener {
private final ShopPoints shops; private final ShopPoints shops;
private ActionPoller poller; private ActionPoller poller;
private MapService mapService; private MapService mapService;
private Object blueMap;
private EconomyHandler economy; private EconomyHandler economy;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) { ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
@@ -64,7 +63,7 @@ final class ClientLinkBridge implements PluginMessageListener {
} }
/** /**
* Shop points, actions from the map, and the BlueMap addon. Called once the command handlers exist; any of them may be * Shop points, actions from the map, and the claims and pins for the SCOPENET Map. Called once the command handlers exist; any of them may be
* null when that system is switched off. * null when that system is switched off.
*/ */
void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) { void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) {
@@ -81,11 +80,9 @@ final class ClientLinkBridge implements PluginMessageListener {
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); } @Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
}; };
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(), mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
plugin.getConfig().getBoolean("bluemap.show-homes", false), Bukkit.getServer().getName()); plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName());
integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString());
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20); Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20);
if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) {
blueMap = BlueMapHook.start(mapService, plugin.getLogger());
}
} }
/** /market point add|remove|list, on the block the player is looking at (or standing on). */ /** /market point add|remove|list, on the block the player is looking at (or standing on). */
@@ -113,20 +110,7 @@ final class ClientLinkBridge implements PluginMessageListener {
} }
} }
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
private static final class BlueMapHook {
static Object start(MapService service, Logger log) {
return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() {
@Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
}, log);
}
static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); }
}
void stop() { void stop() {
if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; }
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C); Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this); Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
} }
@@ -51,10 +51,19 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Claim protection runs from a local copy of the panel's claims, refreshed in the background. // Claim protection runs from a local copy of the panel's claims, refreshed in the background.
integration.startClaimSync(); integration.startClaimSync();
// Live positions center the in-app BlueMap claim view on the player. // The SCOPENET Map: renders the world to tiles and sends them, positions, claims and pins to the panel.
if (getConfig().getBoolean("livemap.enabled", true)) { // Started on the first tick so the worlds exist.
integration.enableLiveMap(); if (getConfig().getBoolean("map.enabled", getConfig().getBoolean("livemap.enabled", true))) {
Bukkit.getScheduler().runTask(this, () -> {
if (integration == null || Bukkit.getWorlds().isEmpty()) return;
integration.enableMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), getDataFolder().toPath().resolve("map"));
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20); Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
});
getServer().getPluginManager().registerEvents(new org.bukkit.event.Listener() {
@org.bukkit.event.EventHandler public void onSave(org.bukkit.event.world.WorldSaveEvent event) {
if (integration != null) integration.mapSaved();
}
}, this);
} }
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager, // Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
@@ -114,7 +123,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
bindCommand("autoclaim", guilds); bindCommand("autoclaim", guilds);
} }
// Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it. // Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws.
clientLink.startMap(essentialsHandler, guildHandler, economy); clientLink.startMap(essentialsHandler, guildHandler, economy);
// Reloading an auth plugin cannot silently admit existing sessions. // Reloading an auth plugin cannot silently admit existing sessions.
@@ -160,7 +169,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private void snapshotPlayers() { private void snapshotPlayers() {
if (integration == null) return; if (integration == null) return;
java.util.List<LiveMapSync.Player> list = new java.util.ArrayList<>(); java.util.List<net.scopenet.worldmap.WorldMapSync.Player> list = new java.util.ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) { for (Player p : Bukkit.getOnlinePlayers()) {
Location at = p.getLocation(); Location at = p.getLocation();
String dim = switch (at.getWorld().getEnvironment()) { String dim = switch (at.getWorld().getEnvironment()) {
@@ -169,11 +178,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
default -> "minecraft:overworld"; default -> "minecraft:overworld";
}; };
// Rounded so an idle roster doesn't look "changed" on every tick. // Rounded so an idle roster doesn't look "changed" on every tick.
list.add(new LiveMapSync.Player(p.getUniqueId().toString(), p.getName(), dim, list.add(new net.scopenet.worldmap.WorldMapSync.Player(p.getUniqueId().toString(), p.getName(), dim,
Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0, Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0,
Math.round(at.getYaw()), Math.round(at.getPitch()))); Math.round(at.getYaw()), Math.round(at.getPitch())));
} }
integration.liveMapPlayers(list); integration.mapPlayers(list);
} }
public static String dimension(World world) { public static String dimension(World world) {
@@ -51,10 +51,13 @@ essentials:
# RTP search radius # RTP search radius
rtp-radius: 2500 rtp-radius: 2500
livemap: map:
# Send live player positions to the panel for the launcher BlueMap claim view. # The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed,
# Configure BlueMap's public address and enable live positions in the panel. # with player positions, guild claims and pins, to the panel. Players see it in the launcher; admins in the panel.
# It also needs the map switched on for this server in the panel (it is by default).
enabled: true enabled: true
# Show every player's homes as pins. Off by default: homes are private.
show-homes: false
economy: economy:
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions) # Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
@@ -93,17 +96,6 @@ integrations:
enabled: true enabled: true
report-interval-seconds: 30 report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# ==============================================================================
bluemap:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# ============================================================================== # ==============================================================================
# Client mod link # Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET # Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -4,7 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20' api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting. description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap] softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
commands: commands:
scopenet: scopenet:
+1 -1
View File
@@ -6,7 +6,7 @@ pluginManagement {
} }
} }
rootProject.name = 'scopenet-integrations' rootProject.name = 'scopenet-integrations'
include 'common', 'api', 'paper', 'bluemap' include 'common', 'api', 'paper'
// Build loaders separately: Forge and Loom use different Gradle generations. // Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric' if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge' if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'