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:
Claude committed 2026-09-30 17:15:24 +00:00
1 parent 8acb4a64e6
commit fdc54577fd
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
+41 -2
View File
@@ -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);
} }
+2
View File
@@ -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>
+12 -1
View File
@@ -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>
+18 -1
View File
@@ -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';
+5 -2
View File
@@ -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">
+2 -2
View File
@@ -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;
} }