From fdc54577fd3add74a8e8689de172322d3b15d193 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:15:24 +0000 Subject: [PATCH] Live map: plugin/mod chunk+player sync, admin panel Live Map section, launcher transport Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- .../net/scopenet/integration/Integration.java | 25 ++ .../net/scopenet/integration/LiveMapSync.java | 363 ++++++++++++++++++ .../net/scopenet/integration/PanelClient.java | 35 ++ .../net/scopenet/integration/Settings.java | 15 + .../java/net/scopenet/minecraft/Bridge.java | 24 ++ .../net/scopenet/paper/ScopenetPlugin.java | 29 ++ .../paper/src/main/resources/config.yml | 6 + launcher/src-tauri/src/commands.rs | 43 ++- launcher/src-tauri/src/lib.rs | 2 + panel/web/src/components/LiveMapPanel.svelte | 192 +++++++++ panel/web/src/components/ServerForm.svelte | 13 +- panel/web/src/lib/types.ts | 19 +- panel/web/src/pages/ServerDetail.svelte | 7 +- panel/web/src/pages/Servers.svelte | 4 +- 14 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java create mode 100644 panel/web/src/components/LiveMapPanel.svelte diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 887b859..5120492 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -19,6 +19,7 @@ public final class Integration implements AutoCloseable { new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy()); private final AtomicBoolean syncing = new AtomicBoolean(); private volatile boolean closed; + private volatile LiveMapSync liveMap; private boolean registered; private JsonObject pending; private long nextSync; @@ -128,6 +129,28 @@ public final class Integration implements AutoCloseable { 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 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) { return client.checkChunk(dimension, chunkX, chunkZ, uuid); } @@ -138,6 +161,8 @@ public final class Integration implements AutoCloseable { @Override public void close() { closed = true; + LiveMapSync map = liveMap; + if (map != null) map.close(); client.close(); loginWorker.shutdownNow(); activity.leaveAll(); diff --git a/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java b/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java new file mode 100644 index 0000000..3d73045 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java @@ -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 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 known = new HashMap<>(); + private final Set manifestLoaded = new HashSet<>(); + private final AtomicBoolean scanQueued = new AtomicBoolean(); + private volatile boolean panelEnabled; + private volatile boolean closed; + private volatile List 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 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 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 files = new ArrayList<>(); + try (DirectoryStream 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 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 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. + } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java index 4c6bfcf..b31fb9f 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java +++ b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java @@ -54,6 +54,41 @@ public final class PanelClient { } } + public HttpClient http() { return client; } + public Settings settings() { return settings; } + + private static JsonObject object(HttpResponse 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 { return postElement(endpoint, payload).getAsJsonObject(); } diff --git a/integrations/common/src/main/java/net/scopenet/integration/Settings.java b/integrations/common/src/main/java/net/scopenet/integration/Settings.java index 857f5d4..dae69fd 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Settings.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Settings.java @@ -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 { if (!Files.exists(path)) { Files.createDirectories(path.toAbsolutePath().getParent()); @@ -127,6 +138,10 @@ public record Settings( social.chat_prefixes=true essentials.enabled=true economy.enabled=true + + # Send world chunks and player positions to the panel's central Live Map. + # It only runs when Live Map is also enabled for this server in the panel. + livemap.enabled=true """; Files.writeString(path, defaultProps, StandardCharsets.UTF_8); } diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index 09be90e..31fe54c 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture; public final class Bridge { private static volatile Integration integration; private static final Map online = new HashMap<>(); + private static int mapTicks; private Bridge() {} public static void start(MinecraftServer server) { @@ -24,12 +25,25 @@ public final class Bridge { ServerPlayer player = server.getPlayerList().getPlayer(id); 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) { // Throwing aborts startup; silently disabling an auth mod is unsafe. 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 login(UUID uuid, String name, SocketAddress address) { Integration current = integration; if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE); @@ -59,6 +73,16 @@ public final class Bridge { return true; }); current.tick(); + if (++mapTicks >= 20) { + mapTicks = 0; + List 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) { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 7ea8507..c9a48b3 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -37,6 +37,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { }); 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 ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration); bindCommand("scopenet", scopenetCmd); @@ -107,6 +113,29 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { integration.reload(next); } + private void snapshotPlayers() { + if (integration == null) return; + java.util.List 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) { return world.getKey().toString(); } diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index aaedac3..1bebf99 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -51,6 +51,12 @@ essentials: # RTP search radius 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: # Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions) enabled: true diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index adb15d0..e873ec1 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -637,7 +637,6 @@ pub struct MapResource { /// Only paths under a map URL supplied by the panel can be fetched. #[tauri::command] pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option) -> Res { - use base64::Engine; 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 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()); } + 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 { + 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//` can be fetched. +#[tauri::command] +pub async fn fetch_livemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option) -> Res { + 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, bearer: Option) -> Res { + use base64::Engine; if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); } let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(60)) .build() .map_err(err)?; 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')) { request = request.header(reqwest::header::IF_NONE_MATCH, etag); } diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 34dc7b2..33df5f7 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -92,6 +92,8 @@ pub fn run() { commands::get_leaderboard, commands::get_public_servers, commands::fetch_vantage_resource, + commands::fetch_livemap_resource, + commands::get_livemap_info, commands::get_my_level, commands::get_my_quests, commands::claim_quest, diff --git a/panel/web/src/components/LiveMapPanel.svelte b/panel/web/src/components/LiveMapPanel.svelte new file mode 100644 index 0000000..33cc06f --- /dev/null +++ b/panel/web/src/components/LiveMapPanel.svelte @@ -0,0 +1,192 @@ + + +
+
+

Live Map

+ {!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'} + +
+ + {#if !enabled} +

Turn on Live Map in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.

+ {:else if status} +
+
+ {#if status.generator.installed}{:else}{/if} + Vantage generator + {status.generator.installed ? status.generator.path : 'Not installed on the panel'} +
+
+ {#if status.assets.configured}{:else}{/if} + Minecraft assets + {status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'} +
+
0}> + {#if status.world.level_dat && totalRegions > 0}{:else}{/if} + World data + {totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'} +
+
0}> + {#if status.players > 0}{:else}{/if} + Player positions + {status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'} +
+
+ + {#if !status.generator.installed || !status.assets.configured} +

+ The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets. + Install them as described in docs/live-map.md — until then launchers show a “map is being set up” message. +

+ {/if} + {#if status.last_error}

Generator: {status.last_error}

{/if} + + {#if status.ready} +
+ {#each status.dimensions as d (d.slug)} + + {/each} + + +
+
+
+ {#if loading}
Rendering terrain…
{/if} + {#if viewError}
Map unavailable: {viewError}
{/if} +
+ {:else if status.message} +

{status.message}

+ {/if} + {:else} +
+ {/if} +
+ + +

Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.

+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/panel/web/src/components/ServerForm.svelte b/panel/web/src/components/ServerForm.svelte index db7252a..29c8876 100644 --- a/panel/web/src/components/ServerForm.svelte +++ b/panel/web/src/components/ServerForm.svelte @@ -18,7 +18,15 @@ - + +
+ Use an external Vantage map instead + +
Who can join
@@ -58,5 +66,8 @@ .opt.on :global(svg) { color: var(--accent-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); } + .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%; } diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 3b955d4..5549e07 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -94,6 +94,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub export interface GameServer { instance_id: string; map_url: string; + live_map_enabled: boolean; id: number; name: 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 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 { platform: 'windows' | 'mac' | 'linux'; diff --git a/panel/web/src/pages/ServerDetail.svelte b/panel/web/src/pages/ServerDetail.svelte index d82d630..07497bf 100644 --- a/panel/web/src/pages/ServerDetail.svelte +++ b/panel/web/src/pages/ServerDetail.svelte @@ -4,6 +4,7 @@ CircleDot, Ban, Server, } from '@lucide/svelte'; import Avatar from '../components/Avatar.svelte'; + import LiveMapPanel from '../components/LiveMapPanel.svelte'; import Modal from '../components/Modal.svelte'; import ServerForm from '../components/ServerForm.svelte'; import TokenReveal from '../components/TokenReveal.svelte'; @@ -18,7 +19,7 @@ let sort = $state('playtime_secs'); let groups = $state([]); let info = $state(null); - let draft = $state({ name: '', access: 'all', allowed_groups: [], require_launcher: false }); + let draft = $state({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: false }); let editOpen = $state(false); let token = $state(null); let tokenOpen = $state(false); @@ -78,7 +79,7 @@ function openEdit() { 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; } async function saveEdit() { @@ -147,6 +148,8 @@
Total playtime{duration(d.totals.playtime_secs)}
+ +
diff --git a/panel/web/src/pages/Servers.svelte b/panel/web/src/pages/Servers.svelte index 0f48358..4eb1e47 100644 --- a/panel/web/src/pages/Servers.svelte +++ b/panel/web/src/pages/Servers.svelte @@ -11,7 +11,7 @@ let servers = $state(null); let groups = $state([]); let info = $state(null); - let draft = $state({ name: '', access: 'all', allowed_groups: [], require_launcher: false }); + let draft = $state({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true }); let createOpen = $state(false); let creating = $state(false); 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); 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; }