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());
|
||||
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<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) {
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture;
|
||||
public final class Bridge {
|
||||
private static volatile Integration integration;
|
||||
private static final Map<UUID, String> 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<String> 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<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) {
|
||||
|
||||
@@ -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<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) {
|
||||
return world.getKey().toString();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">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">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
@@ -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%; }
|
||||
</style>
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Group[]>([]);
|
||||
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 token = $state<string | null>(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 @@
|
||||
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||
</div>
|
||||
|
||||
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} />
|
||||
|
||||
<div class="grid layout">
|
||||
<section class="card board">
|
||||
<div class="section-title">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
let servers = $state<GameServer[] | null>(null);
|
||||
let groups = $state<Group[]>([]);
|
||||
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 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user