Migrate CI to Gitea and improve live map diagnostics
CI / Real Minecraft installs (manual) (push) Skipped
CI / Rust, web and container (push) Canceled after 0s
CI / Windows launcher (push) Canceled after 0s
Server integrations / paper (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.20.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.21.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.1.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.3 (push) Canceled after 0s

This commit is contained in:
SCOPEDD committed 2026-10-01 00:07:35 -04:00
1 parent d72d7c2e80
commit 7809d47d39
32 files changed
+518 -325

No files matched your search

@@ -175,6 +175,11 @@ public final class Integration implements AutoCloseable {
if (current != null) current.requestScan();
}
public String mapStatus() {
net.scopenet.worldmap.WorldMapSync current = map;
return current == null ? "Map is not started. Check map.enabled in the server config and restart." : current.status();
}
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
}
@@ -32,6 +32,8 @@ public final class TileStore {
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
public boolean exists(Key k) { return Files.isRegularFile(file(k)); }
/** The tile's pixels, or null if it was never rendered. */
public synchronized int[] load(Key k) {
int[] cached = memory.get(k);
@@ -68,6 +68,14 @@ public final class WorldMapSync implements AutoCloseable {
private long overlaySentAt;
private String lastPlayersSent = "";
private long playersSentAt;
private volatile String stage = "starting";
private volatile String lastError = "";
private volatile String lastRegion = "";
private volatile long regionsRendered;
private volatile long tilesUploaded;
private volatile long lastUploadAt;
private volatile int regionFiles;
private volatile int queuedTiles;
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
this.client = client;
@@ -90,6 +98,15 @@ public final class WorldMapSync implements AutoCloseable {
players = List.copyOf(next);
}
/** Safe to read on the game thread for /map and /scopenet map. */
public String status() {
return "Map " + (enabled ? stage : "waiting for panel/map setting") + "; world=" + worldRoot
+ "; region files=" + regionFiles + "; rendered=" + regionsRendered
+ "; queued tiles=" + queuedTiles + "; uploaded=" + tilesUploaded
+ (lastRegion.isEmpty() ? "" : "; last region=" + lastRegion)
+ (lastError.isEmpty() ? "" : "; error=" + lastError);
}
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
public void setOverlay(java.util.function.Supplier<String> source) {
overlay = source;
@@ -126,12 +143,17 @@ public final class WorldMapSync implements AutoCloseable {
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
lastError = "panel HTTP " + e.status + ": " + e.getMessage();
stage = "upload failed";
pause = 30_000;
} catch (Exception e) {
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
stage = "failed";
pause = 60_000;
}
if (stateDirty) saveState();
queuedTiles = state.dirty.size();
try {
synchronized (wake) {
if (!wakeRequested) wake.wait(pause);
@@ -163,8 +185,12 @@ public final class WorldMapSync implements AutoCloseable {
}
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
enabled = on;
stage = on ? "scanning regions" : "disabled on panel";
if (on) lastError = "";
} catch (PanelClient.HttpFailure e) {
enabled = false; // an older panel without the map
stage = "panel configuration failed";
lastError = "panel HTTP " + e.status + ": " + e.getMessage();
}
return enabled;
}
@@ -172,6 +198,7 @@ public final class WorldMapSync implements AutoCloseable {
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
private boolean cycle() throws IOException, InterruptedException {
if (!Files.isDirectory(worldRoot)) {
stage = "world folder missing";
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
@@ -181,12 +208,15 @@ public final class WorldMapSync implements AutoCloseable {
long now = System.currentTimeMillis();
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
List<Stale> stale = new ArrayList<>();
int filesFound = 0;
int completeFiles = 0;
List<Player> online = players;
for (Dim dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
for (Path file : stream) {
filesFound++;
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
@@ -195,13 +225,14 @@ public final class WorldMapSync implements AutoCloseable {
} catch (RuntimeException e) {
continue;
}
if (Files.size(file) <= 8192) continue;
completeFiles++;
long mtime = Files.getLastModifiedTime(file).toMillis();
String key = dim.dir() + "/" + rx + "." + rz;
Long done = state.regions.get(key);
if (done != null && mtime <= done) continue;
Long last = renderedAt.get(key);
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
if (Files.size(file) < 8192) continue;
if (last != null && now - last < RERENDER_GAP_MS) continue;
double distance = Double.MAX_VALUE;
for (Player p : online) {
if (!dim.id().equals(p.dimension())) continue;
@@ -212,27 +243,41 @@ public final class WorldMapSync implements AutoCloseable {
}
}
}
regionFiles = filesFound;
if (filesFound == 0) stage = "no region files found";
else if (completeFiles == 0) stage = "region files have no saved chunks yet";
boolean worked = false;
if (!stale.isEmpty()) {
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
Stale next = stale.get(0);
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
worked = true;
worked = renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
}
if (flushUploads(stale.size() <= 1)) worked = true;
if (filesFound > 0 && stale.isEmpty() && state.dirty.isEmpty() && state.regions.size() >= filesFound) stage = "caught up";
return worked;
}
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
private boolean renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
stage = "rendering " + key;
lastRegion = key;
int[][] tiles;
try {
tiles = RegionRenderer.render(file, dim.kind());
} catch (IOException | RuntimeException e) {
// A region being written right now; try again after the gap.
renderedAt.put(key, System.currentTimeMillis());
return;
lastError = "render " + key + ": " + e.getMessage();
return false;
}
renderedAt.put(key, System.currentTimeMillis());
if (tiles == null) {
// A region may still be generating or use a format we cannot read. Never mark it complete.
lastError = "No readable full chunks in " + key + "; will retry";
stage = "waiting for readable chunks";
return false;
}
lastError = "";
regionsRendered++;
List<TileStore.Key> changed = new ArrayList<>();
if (tiles != null) {
for (int i = 0; i < 4; i++) {
@@ -261,6 +306,7 @@ public final class WorldMapSync implements AutoCloseable {
}
state.regions.put(key, mtime);
stateDirty = true;
return true;
}
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
@@ -297,6 +343,9 @@ public final class WorldMapSync implements AutoCloseable {
}
if (inBatch.isEmpty()) continue;
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
tilesUploaded += inBatch.size();
lastUploadAt = System.currentTimeMillis();
stage = "uploading tiles";
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
stateDirty = true;
sentAny = true;
@@ -344,12 +393,34 @@ public final class WorldMapSync implements AutoCloseable {
lastOverlaySent = ov;
overlaySentAt = now;
}
if (now - diagnosticsSentAt >= 10_000) {
JsonObject diagnostic = new JsonObject();
diagnostic.addProperty("stage", stage);
diagnostic.addProperty("error", lastError);
diagnostic.addProperty("world_root", worldRoot.toString());
diagnostic.addProperty("region_files", regionFiles);
diagnostic.addProperty("regions_rendered", regionsRendered);
diagnostic.addProperty("queued_tiles", queuedTiles);
diagnostic.addProperty("tiles_uploaded", tilesUploaded);
diagnostic.addProperty("last_region", lastRegion);
diagnostic.addProperty("last_upload_ms", lastUploadAt);
try {
client.post("map/diagnostics", diagnostic);
diagnosticsSentAt = now;
} catch (PanelClient.HttpFailure e) {
// Diagnostics are optional while an older panel is being upgraded.
diagnosticsSentAt = now;
if (e.status != 404) lastError = "diagnostics HTTP " + e.status + ": " + e.getMessage();
}
}
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
} catch (Exception ignored) {
// the next tick retries
lastError = "live upload HTTP " + e.status + ": " + e.getMessage();
} catch (Exception e) {
lastError = "live upload: " + e.getMessage();
}
}
private long diagnosticsSentAt;
// ---- world layout ----
@@ -378,7 +449,23 @@ public final class WorldMapSync implements AutoCloseable {
Path f = cacheDir.resolve("state.json");
if (Files.isRegularFile(f)) {
State s = gson.fromJson(Files.readString(f), State.class);
if (s != null && s.regions != null && s.dirty != null) state = s;
if (s != null && s.regions != null && s.dirty != null) {
state = s;
// Older builds could mark an unreadable region complete without producing a tile.
// Clear those entries on upgrade so a live server can recover without an admin reset.
state.regions.keySet().removeIf(key -> {
String[] parts = key.split("[/ .]");
if (parts.length != 3) return true;
try {
int rx = Integer.parseInt(parts[1]), rz = Integer.parseInt(parts[2]);
for (int i = 0; i < 4; i++) {
if (store.exists(new TileStore.Key(parts[0], 0, rx * 2 + (i & 1), rz * 2 + (i >> 1)))) return false;
}
} catch (NumberFormatException e) { return true; }
return true;
});
stateDirty = true;
}
}
} catch (IOException | RuntimeException e) {
state = new State();
@@ -19,7 +19,7 @@ final class ScopenetCommand implements CoreCommand {
@Override public String permission() { return "scopenet.command.scopenet"; }
@Override public List<String> complete(CorePlayer player, String[] args) {
return args.length <= 1 ? List.of("help", "status", "reload") : List.of();
return args.length <= 1 ? List.of("help", "status", "map", "reload") : List.of();
}
@Override public void run(CorePlayer p, String[] args) {
@@ -33,6 +33,11 @@ final class ScopenetCommand implements CoreCommand {
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
features.sendStatus(p);
}
case "map" -> {
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to view map status."); return; }
net.scopenet.integration.Integration integration = net.scopenet.minecraft.Bridge.integration();
p.send(Format.GOLD + "SCOPENET Map: " + Format.WHITE + (integration == null ? "Integration unavailable" : integration.mapStatus()));
}
case "reload" -> {
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
features.reload(p);
@@ -51,5 +56,6 @@ final class ScopenetCommand implements CoreCommand {
p.send(Format.YELLOW + " /" + c.name() + aliases);
}
p.send(Format.GRAY + "Open the launcher's Command guide for what each one does.");
p.send(Format.YELLOW + " /scopenet map" + Format.GRAY + " - Map rendering and upload progress");
}
}
@@ -87,6 +87,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register /scopenet admin & help command
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
bindCommand("scopenet", scopenetCmd);
bindCommand("map", scopenetCmd);
// Register Essentials commands & GUI
{
@@ -22,6 +22,10 @@ public final class ScopenetCommandHandler implements CommandExecutor {
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (command.getName().equalsIgnoreCase("map")) {
sendMap(sender);
return true;
}
if (args.length == 0 || args[0].equalsIgnoreCase("help")) {
if (!sender.hasPermission("scopenet.command.scopenet.help")) {
sender.sendMessage(ChatColor.RED + "You do not have permission to view SCOPENET help.");
@@ -34,6 +38,7 @@ public final class ScopenetCommandHandler implements CommandExecutor {
String sub = args[0].toLowerCase();
switch (sub) {
case "status" -> sendStatus(sender);
case "map" -> sendMap(sender);
case "reload" -> handleReload(sender);
default -> {
sender.sendMessage(ChatColor.RED + "Unknown command. Use " + ChatColor.YELLOW + "/scopenet help");
@@ -92,6 +97,7 @@ public final class ScopenetCommandHandler implements CommandExecutor {
if (sender.hasPermission("scopenet.admin")) {
sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]");
sender.sendMessage(ChatColor.YELLOW + " /scopenet status" + ChatColor.GRAY + " - Integration health and module statuses");
sender.sendMessage(ChatColor.YELLOW + " /map" + ChatColor.GRAY + " - Map rendering and upload progress");
sender.sendMessage(ChatColor.YELLOW + " /scopenet reload" + ChatColor.GRAY + " - Reload config and feature flags");
}
}
@@ -137,6 +143,14 @@ public final class ScopenetCommandHandler implements CommandExecutor {
}
}
private void sendMap(CommandSender sender) {
if (!sender.hasPermission("scopenet.command.scopenet.status")) {
sender.sendMessage(ChatColor.RED + "You do not have permission to view map status.");
return;
}
sender.sendMessage(ChatColor.GOLD + "SCOPENET Map: " + ChatColor.WHITE + integration.mapStatus());
}
private String formatFlag(boolean enabled) {
return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED";
}
@@ -11,6 +11,9 @@ commands:
permission: scopenet.command.scopenet
description: SCOPENET server commands, status, and help
aliases: [sn]
map:
permission: scopenet.command.scopenet.status
description: Show live map rendering and upload progress
spawn:
permission: scopenet.command.spawn
description: Teleport to the server spawn point