Live map: plugin/mod chunk+player sync, admin panel Live Map section, launcher transport
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
14 files changed
+769
-8
No files matched your search
@@ -19,6 +19,7 @@ public final class Integration implements AutoCloseable {
|
|||||||
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
|
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
|
||||||
private final AtomicBoolean syncing = new AtomicBoolean();
|
private final AtomicBoolean syncing = new AtomicBoolean();
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
private volatile LiveMapSync liveMap;
|
||||||
private boolean registered;
|
private boolean registered;
|
||||||
private JsonObject pending;
|
private JsonObject pending;
|
||||||
private long nextSync;
|
private long nextSync;
|
||||||
@@ -128,6 +129,28 @@ public final class Integration implements AutoCloseable {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start mirroring this server's world and player positions to the panel's
|
||||||
|
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
|
||||||
|
*/
|
||||||
|
public void enableLiveMap(java.nio.file.Path worldRoot) {
|
||||||
|
if (closed || liveMap != null) return;
|
||||||
|
liveMap = new LiveMapSync(client, worldRoot, log);
|
||||||
|
liveMap.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The world was just saved: pick up its changed chunks soon. */
|
||||||
|
public void liveMapSaved() {
|
||||||
|
LiveMapSync map = liveMap;
|
||||||
|
if (map != null) map.requestScan();
|
||||||
|
}
|
||||||
|
|
||||||
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
||||||
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
||||||
}
|
}
|
||||||
@@ -138,6 +161,8 @@ public final class Integration implements AutoCloseable {
|
|||||||
|
|
||||||
@Override public void close() {
|
@Override public void close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
LiveMapSync map = liveMap;
|
||||||
|
if (map != null) map.close();
|
||||||
client.close();
|
client.close();
|
||||||
loginWorker.shutdownNow();
|
loginWorker.shutdownNow();
|
||||||
activity.leaveAll();
|
activity.leaveAll();
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package net.scopenet.integration;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
|
||||||
|
* already-compressed Anvil payloads read straight from the region files) over
|
||||||
|
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
|
||||||
|
* Nothing here touches the server thread except {@link #offerPlayers}.
|
||||||
|
*/
|
||||||
|
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 static final String[] DIMS = {"overworld", "the_nether", "the_end"};
|
||||||
|
private static final int SECTOR = 4096;
|
||||||
|
private static final int BATCH_BYTES = 6 * 1024 * 1024;
|
||||||
|
private static final int SCAN_BUDGET_BYTES = 48 * 1024 * 1024;
|
||||||
|
|
||||||
|
private final PanelClient client;
|
||||||
|
private final Path root;
|
||||||
|
private final Consumer<String> log;
|
||||||
|
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "scopenet-livemap");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
|
||||||
|
private final Map<String, int[]> known = new HashMap<>();
|
||||||
|
private final Set<String> manifestLoaded = new HashSet<>();
|
||||||
|
private final AtomicBoolean scanQueued = new AtomicBoolean();
|
||||||
|
private volatile boolean panelEnabled;
|
||||||
|
private volatile boolean closed;
|
||||||
|
private volatile List<Player> players = List.of();
|
||||||
|
private volatile boolean playersChanged;
|
||||||
|
private volatile WebSocket socket;
|
||||||
|
private volatile long socketRetryAt;
|
||||||
|
private long nextScan;
|
||||||
|
private long configCheckAt;
|
||||||
|
private long levelModified = -1;
|
||||||
|
private long lastPlayersSent;
|
||||||
|
private long chunkIntervalSecs = 20;
|
||||||
|
private boolean warnedMissing;
|
||||||
|
|
||||||
|
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
|
||||||
|
this.client = client;
|
||||||
|
this.root = root;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
|
||||||
|
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void offerPlayers(List<Player> next) {
|
||||||
|
if (!next.equals(players)) {
|
||||||
|
players = List.copyOf(next);
|
||||||
|
playersChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A save just happened; scan soon instead of waiting for the next interval. */
|
||||||
|
public void requestScan() {
|
||||||
|
if (closed || !scanQueued.compareAndSet(false, true)) return;
|
||||||
|
try {
|
||||||
|
worker.schedule(() -> {
|
||||||
|
scanQueued.set(false);
|
||||||
|
nextScan = 0;
|
||||||
|
scanSafely();
|
||||||
|
}, 3, TimeUnit.SECONDS);
|
||||||
|
} catch (RejectedExecutionException e) {
|
||||||
|
scanQueued.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void close() {
|
||||||
|
closed = true;
|
||||||
|
WebSocket ws = socket;
|
||||||
|
if (ws != null) ws.abort();
|
||||||
|
worker.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chunks ----
|
||||||
|
|
||||||
|
private void scanSafely() {
|
||||||
|
if (closed || System.nanoTime() < nextScan) return;
|
||||||
|
try {
|
||||||
|
if (!refreshEnabled()) return;
|
||||||
|
long uploaded = scan();
|
||||||
|
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
|
||||||
|
} catch (PanelClient.HttpFailure e) {
|
||||||
|
if (e.status == 403) panelEnabled = false;
|
||||||
|
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
|
||||||
|
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
|
||||||
|
private boolean refreshEnabled() throws IOException, InterruptedException {
|
||||||
|
if (panelEnabled) return true;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
if (now < configCheckAt) return false;
|
||||||
|
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
|
||||||
|
try {
|
||||||
|
JsonObject config = client.get("livemap/config");
|
||||||
|
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
|
||||||
|
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
|
||||||
|
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
|
||||||
|
} catch (PanelClient.HttpFailure e) {
|
||||||
|
panelEnabled = false; // an older panel without live map support
|
||||||
|
}
|
||||||
|
return panelEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns how many bytes of chunk data were uploaded. */
|
||||||
|
private long scan() throws IOException, InterruptedException {
|
||||||
|
if (!Files.isDirectory(root)) {
|
||||||
|
if (!warnedMissing) {
|
||||||
|
warnedMissing = true;
|
||||||
|
log.accept("SCOPENET live map: world folder " + root + " not found");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uploadLevelIfChanged();
|
||||||
|
long total = 0;
|
||||||
|
for (String dim : DIMS) {
|
||||||
|
Path dir = regionDir(dim);
|
||||||
|
if (dir == null) continue;
|
||||||
|
loadManifest(dim);
|
||||||
|
Batch batch = new Batch(dim);
|
||||||
|
List<Path> files = new ArrayList<>();
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
|
||||||
|
stream.forEach(files::add);
|
||||||
|
}
|
||||||
|
files.sort(Comparator.comparing(Path::toString));
|
||||||
|
for (Path file : files) {
|
||||||
|
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
|
||||||
|
total += readRegion(dim, file, batch);
|
||||||
|
}
|
||||||
|
total += batch.flush();
|
||||||
|
if (total >= SCAN_BUDGET_BYTES) break;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path regionDir(String dim) {
|
||||||
|
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
|
||||||
|
Path parent = root.toAbsolutePath().getParent();
|
||||||
|
List<Path> candidates = switch (dim) {
|
||||||
|
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
|
||||||
|
root.resolve("dimensions/minecraft/the_nether/region"));
|
||||||
|
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
|
||||||
|
root.resolve("dimensions/minecraft/the_end/region"));
|
||||||
|
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
|
||||||
|
};
|
||||||
|
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path sibling(Path parent, String name, String rel) {
|
||||||
|
return parent == null ? null : parent.resolve(name).resolve(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadManifest(String dim) throws IOException, InterruptedException {
|
||||||
|
if (manifestLoaded.contains(dim)) return;
|
||||||
|
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
|
||||||
|
for (JsonElement el : manifest.getAsJsonArray("regions")) {
|
||||||
|
JsonObject region = el.getAsJsonObject();
|
||||||
|
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
|
||||||
|
int[] ts = new int[1024];
|
||||||
|
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
|
||||||
|
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
|
||||||
|
}
|
||||||
|
manifestLoaded.add(dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String dim, int rx, int rz) {
|
||||||
|
return dim + ":" + rx + ":" + rz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
|
||||||
|
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
|
||||||
|
String[] parts = file.getFileName().toString().split("\\.");
|
||||||
|
int rx, rz;
|
||||||
|
try {
|
||||||
|
rx = Integer.parseInt(parts[1]);
|
||||||
|
rz = Integer.parseInt(parts[2]);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long flushed = 0;
|
||||||
|
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
|
||||||
|
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
|
||||||
|
long size = ch.size();
|
||||||
|
if (size < 2L * SECTOR) return 0;
|
||||||
|
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
|
||||||
|
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
|
||||||
|
for (int i = 0; i < 1024; i++) {
|
||||||
|
int loc = header.getInt(i * 4);
|
||||||
|
int ts = header.getInt(SECTOR + i * 4);
|
||||||
|
int count = loc & 0xff;
|
||||||
|
long offset = (loc >>> 8) & 0xffffffL;
|
||||||
|
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
|
||||||
|
if (offset < 2 || (offset + count) * SECTOR > size) continue;
|
||||||
|
ByteBuffer prefix = ByteBuffer.allocate(5);
|
||||||
|
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
|
||||||
|
if (prefix.hasRemaining()) continue;
|
||||||
|
int length = prefix.getInt(0);
|
||||||
|
int compression = prefix.get(4) & 0xff;
|
||||||
|
// Oversized chunks live in external .mcc files; skip those and anything malformed.
|
||||||
|
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
|
||||||
|
ByteBuffer data = ByteBuffer.allocate(length - 1);
|
||||||
|
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
|
||||||
|
if (data.hasRemaining()) continue;
|
||||||
|
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
|
||||||
|
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flushed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
|
||||||
|
private final class Batch {
|
||||||
|
final String dim;
|
||||||
|
final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
final List<int[]> marks = new ArrayList<>();
|
||||||
|
int size;
|
||||||
|
|
||||||
|
Batch(String dim) {
|
||||||
|
this.dim = dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
|
||||||
|
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
|
||||||
|
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
|
||||||
|
out.write(rec.array(), 0, rec.capacity());
|
||||||
|
marks.add(new int[]{rx, rz, index, ts});
|
||||||
|
size += rec.capacity();
|
||||||
|
}
|
||||||
|
|
||||||
|
long flush() throws IOException, InterruptedException {
|
||||||
|
if (marks.isEmpty()) return 0;
|
||||||
|
long sent = size;
|
||||||
|
try {
|
||||||
|
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
|
||||||
|
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
|
||||||
|
} finally {
|
||||||
|
out.reset();
|
||||||
|
marks.clear();
|
||||||
|
size = 0;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadLevelIfChanged() throws IOException, InterruptedException {
|
||||||
|
Path level = root.resolve("level.dat");
|
||||||
|
if (!Files.isRegularFile(level)) return;
|
||||||
|
long modified = Files.getLastModifiedTime(level).toMillis();
|
||||||
|
if (modified == levelModified) return;
|
||||||
|
byte[] bytes = Files.readAllBytes(level);
|
||||||
|
if (bytes.length > 2 * 1024 * 1024) return;
|
||||||
|
client.postBytes("livemap/level", bytes);
|
||||||
|
levelModified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- players ----
|
||||||
|
|
||||||
|
private void sendPlayersSafely() {
|
||||||
|
if (closed || !panelEnabled) return;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
// Resend unchanged rosters now and then so the panel knows we're alive.
|
||||||
|
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
|
||||||
|
if (!playersChanged && !heartbeat) return;
|
||||||
|
playersChanged = false;
|
||||||
|
lastPlayersSent = now;
|
||||||
|
JsonArray list = new JsonArray();
|
||||||
|
for (Player p : players) {
|
||||||
|
JsonObject o = new JsonObject();
|
||||||
|
o.addProperty("uuid", p.uuid());
|
||||||
|
o.addProperty("name", p.name());
|
||||||
|
o.addProperty("dimension", p.dimension());
|
||||||
|
o.addProperty("x", p.x());
|
||||||
|
o.addProperty("y", p.y());
|
||||||
|
o.addProperty("z", p.z());
|
||||||
|
o.addProperty("yaw", p.yaw());
|
||||||
|
o.addProperty("pitch", p.pitch());
|
||||||
|
list.add(o);
|
||||||
|
}
|
||||||
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("type", "players");
|
||||||
|
body.add("players", list);
|
||||||
|
try {
|
||||||
|
WebSocket ws = socket;
|
||||||
|
if (ws != null && !ws.isOutputClosed()) {
|
||||||
|
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectSocket();
|
||||||
|
client.post("livemap/players", body);
|
||||||
|
} catch (PanelClient.HttpFailure e) {
|
||||||
|
if (e.status == 403) panelEnabled = false;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
socket = null; // reconnect later; HTTPS covers the gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectSocket() {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
if (now < socketRetryAt) return;
|
||||||
|
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
|
||||||
|
try {
|
||||||
|
Settings settings = client.settings();
|
||||||
|
URI panel = settings.panel();
|
||||||
|
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
|
||||||
|
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
|
||||||
|
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
|
||||||
|
client.http().newWebSocketBuilder()
|
||||||
|
.header("Authorization", "Bearer " + settings.token())
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.buildAsync(uri, new WebSocket.Listener() {
|
||||||
|
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
|
||||||
|
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
|
||||||
|
ws.request(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
|
||||||
|
if (socket == ws) socket = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onError(WebSocket ws, Throwable error) {
|
||||||
|
if (socket == ws) socket = null;
|
||||||
|
}
|
||||||
|
}).thenAccept(ws -> {
|
||||||
|
if (closed) ws.abort();
|
||||||
|
else socket = ws;
|
||||||
|
});
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// The HTTPS fallback keeps working.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,41 @@ public final class PanelClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HttpClient http() { return client; }
|
||||||
|
public Settings settings() { return settings; }
|
||||||
|
|
||||||
|
private static JsonObject object(HttpResponse<String> response) throws IOException {
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
String msg = "HTTP " + response.statusCode();
|
||||||
|
try {
|
||||||
|
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
|
if (err.has("message")) msg = err.get("message").getAsString();
|
||||||
|
else if (err.has("error")) msg = err.get("error").getAsString();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
throw new HttpFailure(response.statusCode(), msg);
|
||||||
|
}
|
||||||
|
try { return JsonParser.parseString(response.body()).getAsJsonObject(); }
|
||||||
|
catch (RuntimeException e) { throw new IOException("Invalid panel response", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET an endpoint under /api/server/v1/ that returns a JSON object. */
|
||||||
|
public JsonObject get(String endpoint) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||||
|
.timeout(Duration.ofSeconds(15))
|
||||||
|
.header("Authorization", "Bearer " + settings.token()).GET().build();
|
||||||
|
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST raw bytes (live map chunk batches and level.dat). */
|
||||||
|
public JsonObject postBytes(String endpoint, byte[] body) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||||
|
.timeout(Duration.ofSeconds(60))
|
||||||
|
.header("Authorization", "Bearer " + settings.token())
|
||||||
|
.header("Content-Type", "application/octet-stream")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
|
||||||
|
return object(client.send(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
|
}
|
||||||
|
|
||||||
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||||
return postElement(endpoint, payload).getAsJsonObject();
|
return postElement(endpoint, payload).getAsJsonObject();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ 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) {
|
||||||
|
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
properties.load(reader);
|
||||||
|
return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static Settings load(Path path) throws IOException {
|
public static Settings load(Path path) throws IOException {
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(path)) {
|
||||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||||
@@ -127,6 +138,10 @@ public record Settings(
|
|||||||
social.chat_prefixes=true
|
social.chat_prefixes=true
|
||||||
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.
|
||||||
|
# It only runs when Live Map is also enabled for this server in the panel.
|
||||||
|
livemap.enabled=true
|
||||||
""";
|
""";
|
||||||
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
public final class Bridge {
|
public final class Bridge {
|
||||||
private static volatile Integration integration;
|
private static volatile Integration integration;
|
||||||
private static final Map<UUID, String> online = new HashMap<>();
|
private static final Map<UUID, String> online = new HashMap<>();
|
||||||
|
private static int mapTicks;
|
||||||
private Bridge() {}
|
private Bridge() {}
|
||||||
|
|
||||||
public static void start(MinecraftServer server) {
|
public static void start(MinecraftServer server) {
|
||||||
@@ -24,12 +25,25 @@ public final class Bridge {
|
|||||||
ServerPlayer player = server.getPlayerList().getPlayer(id);
|
ServerPlayer player = server.getPlayerList().getPlayer(id);
|
||||||
if (player != null) player.connection.disconnect(Component.literal(message));
|
if (player != null) player.connection.disconnect(Component.literal(message));
|
||||||
}));
|
}));
|
||||||
|
Path config = Path.of("config/scopenet.properties");
|
||||||
|
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder());
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The level folder: `level-name` from server.properties, like the game itself reads it. */
|
||||||
|
private static Path worldFolder() {
|
||||||
|
Properties props = new Properties();
|
||||||
|
try (var in = java.nio.file.Files.newBufferedReader(Path.of("server.properties"))) {
|
||||||
|
props.load(in);
|
||||||
|
} catch (java.io.IOException ignored) {
|
||||||
|
// Fall back to the default level name.
|
||||||
|
}
|
||||||
|
return Path.of(props.getProperty("level-name", "world"));
|
||||||
|
}
|
||||||
|
|
||||||
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
|
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
|
||||||
Integration current = integration;
|
Integration current = integration;
|
||||||
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
|
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
|
||||||
@@ -59,6 +73,16 @@ public final class Bridge {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
current.tick();
|
current.tick();
|
||||||
|
if (++mapTicks >= 20) {
|
||||||
|
mapTicks = 0;
|
||||||
|
List<LiveMapSync.Player> positions = new ArrayList<>();
|
||||||
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||||
|
positions.add(new LiveMapSync.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.getYRot()), Math.round(player.getXRot())));
|
||||||
|
}
|
||||||
|
current.liveMapPlayers(positions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void stat(ServerPlayer player, String key, int amount) {
|
public static void stat(ServerPlayer player, String key, int amount) {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
});
|
});
|
||||||
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
||||||
|
|
||||||
|
// Central Live Map: mirror chunks and player positions to the panel.
|
||||||
|
if (getConfig().getBoolean("livemap.enabled", true)) {
|
||||||
|
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath());
|
||||||
|
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
|
||||||
|
}
|
||||||
|
|
||||||
// Register /scopenet admin & help command
|
// Register /scopenet admin & help command
|
||||||
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration);
|
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration);
|
||||||
bindCommand("scopenet", scopenetCmd);
|
bindCommand("scopenet", scopenetCmd);
|
||||||
@@ -107,6 +113,29 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
integration.reload(next);
|
integration.reload(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void snapshotPlayers() {
|
||||||
|
if (integration == null) return;
|
||||||
|
java.util.List<LiveMapSync.Player> list = new java.util.ArrayList<>();
|
||||||
|
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||||
|
Location at = p.getLocation();
|
||||||
|
String dim = switch (at.getWorld().getEnvironment()) {
|
||||||
|
case NETHER -> "minecraft:the_nether";
|
||||||
|
case THE_END -> "minecraft:the_end";
|
||||||
|
default -> "minecraft:overworld";
|
||||||
|
};
|
||||||
|
// Rounded so an idle roster doesn't look "changed" on every tick.
|
||||||
|
list.add(new LiveMapSync.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.getYaw()), Math.round(at.getPitch())));
|
||||||
|
}
|
||||||
|
integration.liveMapPlayers(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void worldSaved(org.bukkit.event.world.WorldSaveEvent event) {
|
||||||
|
if (integration != null) integration.liveMapSaved();
|
||||||
|
}
|
||||||
|
|
||||||
public static String dimension(World world) {
|
public static String dimension(World world) {
|
||||||
return world.getKey().toString();
|
return world.getKey().toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ essentials:
|
|||||||
# RTP search radius
|
# RTP search radius
|
||||||
rtp-radius: 2500
|
rtp-radius: 2500
|
||||||
|
|
||||||
|
livemap:
|
||||||
|
# Send world chunks and live player positions to the panel's central Live Map.
|
||||||
|
# Players open it from the launcher; you don't run Vantage on this server.
|
||||||
|
# It also needs "Live Map" switched on for this server in the panel.
|
||||||
|
enabled: true
|
||||||
|
|
||||||
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)
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -637,7 +637,6 @@ pub struct MapResource {
|
|||||||
/// Only paths under a map URL supplied by the panel can be fetched.
|
/// Only paths under a map URL supplied by the panel can be fetched.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||||
use base64::Engine;
|
|
||||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||||
let base = reqwest::Url::parse(&configured).map_err(err)?;
|
let base = reqwest::Url::parse(&configured).map_err(err)?;
|
||||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||||
@@ -651,15 +650,55 @@ pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64,
|
|||||||
{
|
{
|
||||||
return Err("map asset is outside the configured Vantage world".into());
|
return Err("map asset is outside the configured Vantage world".into());
|
||||||
}
|
}
|
||||||
|
fetch_map_asset(target, etag, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the launcher needs to offer a Live Map button for one game server.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||||
|
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
|
||||||
|
let resp = req.send().await.map_err(err)?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
|
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native transport for the panel-hosted live map. The session token is
|
||||||
|
/// attached here, so it never reaches page scripts, and only URLs under
|
||||||
|
/// this panel's `/api/livemap/<server>/` can be fetched.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_livemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||||
|
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||||
|
let token = accounts::panel_token(&state).ok_or("sign in to view the live map")?;
|
||||||
|
let base = reqwest::Url::parse(&format!("{}/api/livemap/{server_id}/", panel.trim_end_matches('/'))).map_err(err)?;
|
||||||
|
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||||
|
if target.origin() != base.origin()
|
||||||
|
|| !target.path().starts_with(base.path())
|
||||||
|
|| target.username() != ""
|
||||||
|
|| target.password().is_some()
|
||||||
|
|| target.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("map asset is outside this server's live map".into());
|
||||||
|
}
|
||||||
|
fetch_map_asset(target, etag, Some(token)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
|
||||||
|
use base64::Engine;
|
||||||
if target.as_str().len() > 2048 {
|
if target.as_str().len() > 2048 {
|
||||||
return Err("map asset URL is too long".into());
|
return Err("map asset URL is too long".into());
|
||||||
}
|
}
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
.build()
|
.build()
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
let mut request = client.get(target);
|
let mut request = client.get(target);
|
||||||
|
if let Some(token) = bearer {
|
||||||
|
request = request.bearer_auth(token);
|
||||||
|
}
|
||||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ pub fn run() {
|
|||||||
commands::get_leaderboard,
|
commands::get_leaderboard,
|
||||||
commands::get_public_servers,
|
commands::get_public_servers,
|
||||||
commands::fetch_vantage_resource,
|
commands::fetch_vantage_resource,
|
||||||
|
commands::fetch_livemap_resource,
|
||||||
|
commands::get_livemap_info,
|
||||||
commands::get_my_level,
|
commands::get_my_level,
|
||||||
commands::get_my_quests,
|
commands::get_my_quests,
|
||||||
commands::claim_quest,
|
commands::claim_quest,
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte';
|
||||||
|
import { del, formatBytes, get, timeAgo } from '../lib/api';
|
||||||
|
import { session } from '../lib/session.svelte';
|
||||||
|
import { toast, toastError } from '../lib/toast.svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import type { LiveMapStatus } from '../lib/types';
|
||||||
|
|
||||||
|
let { id, enabled }: { id: number; enabled: boolean } = $props();
|
||||||
|
|
||||||
|
let status = $state<LiveMapStatus | null>(null);
|
||||||
|
let dim = $state('overworld');
|
||||||
|
let host = $state<HTMLDivElement>();
|
||||||
|
let viewer: any = null;
|
||||||
|
let loading = $state(false);
|
||||||
|
let viewError = $state('');
|
||||||
|
let resetOpen = $state(false);
|
||||||
|
let loadedKey = '';
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`);
|
||||||
|
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void id;
|
||||||
|
void enabled;
|
||||||
|
refresh();
|
||||||
|
const t = setInterval(refresh, 10000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeViewer() {
|
||||||
|
viewer?.dispose();
|
||||||
|
viewer = null;
|
||||||
|
loadedKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openViewer() {
|
||||||
|
if (!host || !status?.ready) return;
|
||||||
|
const key = `${id}:${dim}`;
|
||||||
|
if (loadedKey === key && viewer) return;
|
||||||
|
closeViewer();
|
||||||
|
loading = true;
|
||||||
|
viewError = '';
|
||||||
|
try {
|
||||||
|
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
|
||||||
|
import('@thoughts-on-things/vantage-mc/three'),
|
||||||
|
import('@thoughts-on-things/vantage-mc/core'),
|
||||||
|
]);
|
||||||
|
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
|
||||||
|
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
|
||||||
|
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
|
||||||
|
try {
|
||||||
|
await v.load({ world, view: 'top' });
|
||||||
|
} catch (e) {
|
||||||
|
v.dispose();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
viewer = v;
|
||||||
|
loadedKey = key;
|
||||||
|
} catch (e) {
|
||||||
|
viewError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload whenever the dimension changes or the map becomes ready.
|
||||||
|
$effect(() => {
|
||||||
|
void dim;
|
||||||
|
if (status?.ready && host) openViewer();
|
||||||
|
});
|
||||||
|
onDestroy(closeViewer);
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
try {
|
||||||
|
await del(`/api/admin/servers/${id}/livemap`);
|
||||||
|
closeViewer();
|
||||||
|
resetOpen = false;
|
||||||
|
toast('Live map data cleared — the server will re-upload its world');
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
|
||||||
|
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card live">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2><MapIcon size={17} /> Live Map</h2>
|
||||||
|
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span>
|
||||||
|
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !enabled}
|
||||||
|
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p>
|
||||||
|
{:else if status}
|
||||||
|
<div class="checks">
|
||||||
|
<div class="check" class:good={status.generator.installed}>
|
||||||
|
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||||
|
<span><Cpu size={13} /> Vantage generator</span>
|
||||||
|
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="check" class:good={status.assets.configured}>
|
||||||
|
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||||
|
<span><Package size={13} /> Minecraft assets</span>
|
||||||
|
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
|
||||||
|
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||||
|
<span><Boxes size={13} /> World data</span>
|
||||||
|
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="check" class:good={status.players > 0}>
|
||||||
|
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||||
|
<span><Users size={13} /> Player positions</span>
|
||||||
|
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !status.generator.installed || !status.assets.configured}
|
||||||
|
<p class="hint-box small">
|
||||||
|
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
|
||||||
|
Install them as described in <code>docs/live-map.md</code> — until then launchers show a “map is being set up” message.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if}
|
||||||
|
|
||||||
|
{#if status.ready}
|
||||||
|
<div class="tabs">
|
||||||
|
{#each status.dimensions as d (d.slug)}
|
||||||
|
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
|
||||||
|
{/each}
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
|
||||||
|
</div>
|
||||||
|
<div class="stage">
|
||||||
|
<div class="viewer" bind:this={host}></div>
|
||||||
|
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
|
||||||
|
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if status.message}
|
||||||
|
<p class="muted small">{status.message}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="skeleton"></div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Modal bind:open={resetOpen} title="Reset map data?">
|
||||||
|
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
|
||||||
|
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
||||||
|
h2 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; }
|
||||||
|
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; }
|
||||||
|
.pill.off { background: var(--bg-2); color: var(--muted); }
|
||||||
|
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
|
||||||
|
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; }
|
||||||
|
.check.good { color: #6ee7b7; }
|
||||||
|
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.check span :global(svg) { color: var(--muted); }
|
||||||
|
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
|
||||||
|
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
|
||||||
|
.err { color: #fda4af; }
|
||||||
|
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||||
|
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||||
|
.tab:disabled { opacity: 0.4; }
|
||||||
|
.grow { flex: 1; }
|
||||||
|
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
|
||||||
|
.viewer { position: absolute; inset: 0; }
|
||||||
|
.viewer :global(canvas) { display: block; }
|
||||||
|
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
|
||||||
|
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
|
||||||
|
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
|
||||||
|
</style>
|
||||||
@@ -18,7 +18,15 @@
|
|||||||
|
|
||||||
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
|
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
|
||||||
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
|
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
|
||||||
<label class="field">Vantage live map manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional. Use a public Vantage render or a session-gated same-origin endpoint. The URL is visible to launcher users.</span></label>
|
<Toggle
|
||||||
|
bind:checked={draft.live_map_enabled}
|
||||||
|
label="Live Map"
|
||||||
|
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server."
|
||||||
|
/>
|
||||||
|
<details class="adv">
|
||||||
|
<summary>Use an external Vantage map instead</summary>
|
||||||
|
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
|
||||||
|
</details>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="lbl">Who can join</span>
|
<span class="lbl">Who can join</span>
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
@@ -58,5 +66,8 @@
|
|||||||
.opt.on :global(svg) { color: var(--accent-2); }
|
.opt.on :global(svg) { color: var(--accent-2); }
|
||||||
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||||
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||||||
|
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
|
||||||
|
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
|
||||||
|
.adv[open] summary { margin-bottom: 10px; }
|
||||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
</style>
|
</style>
|
||||||
@@ -94,6 +94,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
|
|||||||
export interface GameServer {
|
export interface GameServer {
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
map_url: string;
|
map_url: string;
|
||||||
|
live_map_enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
token_hint: string;
|
token_hint: string;
|
||||||
@@ -142,7 +143,23 @@ export interface ServerDetail {
|
|||||||
|
|
||||||
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
|
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
|
||||||
|
|
||||||
export type ServerDraft = { instance_id?: string; name: string; map_url?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
|
export interface LiveMapStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
message: string;
|
||||||
|
base: string;
|
||||||
|
dimensions: { id: string; slug: string; label: string; available: boolean }[];
|
||||||
|
players: number;
|
||||||
|
generator: { installed: boolean; path: string | null };
|
||||||
|
assets: { configured: boolean; path: string | null };
|
||||||
|
world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] };
|
||||||
|
stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null };
|
||||||
|
running: string[];
|
||||||
|
last_error: string | null;
|
||||||
|
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerDraft = { instance_id?: string; name: string; map_url?: string; live_map_enabled: boolean; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
|
||||||
|
|
||||||
export interface HostedDownload {
|
export interface HostedDownload {
|
||||||
platform: 'windows' | 'mac' | 'linux';
|
platform: 'windows' | 'mac' | 'linux';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
CircleDot, Ban, Server,
|
CircleDot, Ban, Server,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Avatar from '../components/Avatar.svelte';
|
import Avatar from '../components/Avatar.svelte';
|
||||||
|
import LiveMapPanel from '../components/LiveMapPanel.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import ServerForm from '../components/ServerForm.svelte';
|
import ServerForm from '../components/ServerForm.svelte';
|
||||||
import TokenReveal from '../components/TokenReveal.svelte';
|
import TokenReveal from '../components/TokenReveal.svelte';
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
let sort = $state('playtime_secs');
|
let sort = $state('playtime_secs');
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let info = $state<AuthServerInfo | null>(null);
|
let info = $state<AuthServerInfo | null>(null);
|
||||||
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false });
|
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: false });
|
||||||
let editOpen = $state(false);
|
let editOpen = $state(false);
|
||||||
let token = $state<string | null>(null);
|
let token = $state<string | null>(null);
|
||||||
let tokenOpen = $state(false);
|
let tokenOpen = $state(false);
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||||
editOpen = true;
|
editOpen = true;
|
||||||
}
|
}
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
@@ -147,6 +148,8 @@
|
|||||||
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} />
|
||||||
|
|
||||||
<div class="grid layout">
|
<div class="grid layout">
|
||||||
<section class="card board">
|
<section class="card board">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
let servers = $state<GameServer[] | null>(null);
|
let servers = $state<GameServer[] | null>(null);
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let info = $state<AuthServerInfo | null>(null);
|
let info = $state<AuthServerInfo | null>(null);
|
||||||
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false });
|
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true });
|
||||||
let createOpen = $state(false);
|
let createOpen = $state(false);
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let created = $state<{ id: number; token: string } | null>(null);
|
let created = $state<{ id: number; token: string } | null>(null);
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
|
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false };
|
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true };
|
||||||
createOpen = true;
|
createOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user