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
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:
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();
|
||||
|
||||
+7
-1
@@ -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
|
||||
{
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user