Improve MineGeo exploration and mapping features

This commit is contained in:
2026-10-05 13:41:36 +05:00
parent aec0641f87
commit c5368fa8cc
69 changed files with 3581 additions and 333 deletions

View File

@@ -39,7 +39,7 @@ public final class MineGeoPlugin extends JavaPlugin {
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){
if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try {
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo status | cancel | reload"});return true;}
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo server <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — send up to 12 panoramas to its owner","/minegeo status | cancel | reload"});return true;}
switch(args[0].toLowerCase(Locale.ROOT)) {
case "area" -> {
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
@@ -52,9 +52,11 @@ public final class MineGeoPlugin extends JavaPlugin {
case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <http(s)://address:port>");new UploadClient(args[1],"");getConfig().set("website",args[1]);saveConfig();sender.sendMessage("MineGeo website saved: "+args[1]);}
case "server" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo server <hostname[:port]>");String address=ServerAddress.normalize(args[1]);getConfig().set("server-address",address);saveConfig();sender.sendMessage("Capture server address saved: "+address+". Export again to record it.");}
case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link <dashboard token>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");}
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
case "upload" -> upload(sender);
case "upload" -> upload(sender,null);
case "contribute" -> {if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo contribute <map ID>");upload(sender,UUID.fromString(args[1]).toString());}
default -> throw new IllegalArgumentException("Unknown command. Use /minegeo help.");
}
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}return true;
@@ -62,17 +64,18 @@ public final class MineGeoPlugin extends JavaPlugin {
private void requireIdle(){if(busy)throw new IllegalArgumentException("An operation is running. Use /minegeo status or /minegeo cancel.");}
private void report(CommandSender sender,String message){status=message;getLogger().info(message);if(sender instanceof Player&&isEnabled())Bukkit.getScheduler().runTask(this,()->sender.sendMessage("MineGeo: "+message));}
private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);}
private void upload(CommandSender sender)throws Exception {
private void upload(CommandSender sender,String targetMap)throws Exception {
requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link <token>.");
UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview the private draft after processing.");}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,targetMap,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview and submit "+(targetMap==null?"the private map for moderator review.":"the panoramas to the map owner."));}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure.");
}
private record Captured(int cx,int cz,ChunkSnapshot snapshot){}
private record Area(int minX,int minZ,int maxX,int maxZ){}
private void export(CommandSender sender,String name)throws IOException {
requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
String serverAddress=ServerAddress.normalize(getConfig().getString("server-address",""));
String serverName=getConfig().getString("server-name","My Minecraft server");if(serverName.length()<2||serverName.length()>80)throw new IllegalArgumentException("server-name in config.yml must contain 2–80 characters.");
World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
@@ -80,11 +83,11 @@ public final class MineGeoPlugin extends JavaPlugin {
int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(24,Math.max(5,getConfig().getInt("viewpoints",24))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
ArrayBlockingQueue<Captured> queue=new ArrayBlockingQueue<>(4);AtomicBoolean done=new AtomicBoolean();AtomicReference<Exception> failure=new AtomicReference<>();
busy=true;cancelled.set(false);status="Export started: "+out.toAbsolutePath();sender.sendMessage("MineGeo: "+status);
Bukkit.getScheduler().runTaskAsynchronously(this,()->{
try{encode(queue,done,failure,out,name,serverName,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
try{encode(queue,done,failure,out,name,serverName,serverAddress,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
catch(Exception ex){failure.set(ex);fail(sender,"Export",ex);}finally{done.set(true);busy=false;}
});
new BukkitRunnable(){int cx=Math.floorDiv(area.minX,16),cz=Math.floorDiv(area.minZ,16),scanned;final int firstX=cx,lastX=Math.floorDiv(area.maxX-1,16),lastZ=Math.floorDiv(area.maxZ-1,16),total=(lastX-firstX+1)*(lastZ-cz+1);
@@ -98,7 +101,7 @@ public final class MineGeoPlugin extends JavaPlugin {
}catch(Exception ex){failure.set(ex);done.set(true);cancel();}}
}.runTaskTimer(this,1,1);
}
private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,Area area,int lower,int upper,int desired)throws Exception {
private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,String serverAddress,Area area,int lower,int upper,int desired)throws Exception {
JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
JsonArray coverage=new JsonArray(),points=new JsonArray();List<int[]> candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
@@ -123,14 +126,11 @@ public final class MineGeoPlugin extends JavaPlugin {
}
if(coverage.isEmpty())throw new IOException("No generated chunks here. Select an explored area with /minegeo area.");
if(candidates.size()<5)throw new IOException("Need five safe, open surface positions. Select a larger land area (not ocean or treetops).");
List<int[]> chosen=new ArrayList<>();chosen.add(candidates.remove(candidates.size()/2));
while(chosen.size()<desired&&!candidates.isEmpty()){
if(cancelled.get())throw new IOException("Cancelled");int best=0;double bestDistance=-1;
for(int i=0;i<candidates.size();i++){int[] p=candidates.get(i);double distance=Double.MAX_VALUE;for(int[] q:chosen)distance=Math.min(distance,Math.pow(p[0]-q[0],2)+Math.pow(p[2]-q[2],2));if(distance>bestDistance){bestDistance=distance;best=i;}}chosen.add(candidates.remove(best));
}
Collections.shuffle(candidates);
List<int[]> chosen=candidates.subList(0,Math.min(desired,candidates.size()));
for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
if(Files.size(terrain)>50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area.");
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);if(serverAddress!=null)manifest.addProperty("serverAddress",serverAddress);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
JsonArray assets=new JsonArray();JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",Files.size(terrain));MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream input=Files.newInputStream(terrain)){byte[] buffer=new byte[65536];int read;while((read=input.read(buffer))!=-1)digest.update(buffer,0,read);}asset.addProperty("sha256",HexFormat.of().formatHex(digest.digest()));assets.add(asset);manifest.add("assets",assets);
// The manifest is the completion marker. Partial folders never become upload candidates.
if(cancelled.get())throw new IOException("Cancelled");Files.writeString(out.resolve("manifest.json.tmp"),gson.toJson(manifest));Files.move(out.resolve("manifest.json.tmp"),out.resolve("manifest.json"),StandardCopyOption.REPLACE_EXISTING);

View File

@@ -0,0 +1,33 @@
package dev.minegeo.plugin;
import java.net.InetAddress;
import java.util.Locale;
import java.util.regex.Pattern;
import java.io.IOException;
/** Minecraft connection addresses. No DNS lookup or inferred public IP. */
public final class ServerAddress {
private static final Pattern ADDRESS=Pattern.compile("^(\\[[0-9a-f:]+\\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?$");
public static String normalize(String value) {
String input=value==null?"":value.trim().toLowerCase(Locale.ROOT);
if(input.isEmpty())return null;
var match=ADDRESS.matcher(input);
if(input.length()>255||!match.matches())throw new IllegalArgumentException("Use a Minecraft hostname or IP, with an optional port.");
String host=match.group(1);
if(host.startsWith("[")) {
try {if(!host.contains(":"))throw new IllegalArgumentException();InetAddress.getByName(host);}catch(Exception e){throw new IllegalArgumentException("Invalid IPv6 address.");}
} else {
host=host.replaceAll("\\.$","");
if(host.length()>253)throw new IllegalArgumentException("Invalid server hostname.");
for(String label:host.split("\\.",-1))if(!label.matches("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"))throw new IllegalArgumentException("Invalid server hostname.");
}
int port=match.group(2)==null?25565:Integer.parseInt(match.group(2));
if(port<1||port>65535)throw new IllegalArgumentException("Server port must be between 1 and 65535.");
return host+(port==25565?"":":"+port);
}
public static void requireMatch(String expected,String captured) throws IOException {
String server=normalize(expected);
if(server==null)return;
String actual=normalize(captured);
if(!server.equals(actual))throw new IOException("Wrong capture server: this map requires "+server+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again. Plugin admins: configure server-address before exporting. The map owner can correct or clear its server address in settings.");
}
}

View File

@@ -36,10 +36,37 @@ public final class UploadClient {
private void save(Path ledger,JsonObject state)throws IOException{Path tmp=ledger.resolveSibling(ledger.getFileName()+".tmp");Files.writeString(tmp,gson.toJson(state));Files.move(tmp,ledger,StandardCopyOption.REPLACE_EXISTING);}
private String hash(byte[] bytes)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
public void upload(Path directory,Consumer<String> progress,BooleanSupplier cancelled)throws Exception{
upload(directory,null,progress,cancelled);
}
public void upload(Path directory,String targetMap,Consumer<String> progress,BooleanSupplier cancelled)throws Exception{
if(targetMap!=null)targetMap=UUID.fromString(targetMap).toString();
if(token.isBlank())throw new IOException("Link a contributor account first.");
directory=directory.toAbsolutePath().normalize();Path manifestFile=directory.resolve("manifest.json");
if(!Files.isRegularFile(manifestFile))throw new IOException("No completed manifest in "+directory+". Export first, wait for completion, then upload.");
JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject();
if(targetMap!=null){
JsonObject serverInfo=api("/contributions/"+targetMap+"/server",null);
ServerAddress.requireMatch(serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null);
String originalHash=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
Path selectionFile=directory.resolve(".contribution-"+targetMap+".json");
JsonObject cached=Files.exists(selectionFile)?JsonParser.parseString(Files.readString(selectionFile)).getAsJsonObject():new JsonObject();
if(cached.has("hash")&&cached.get("hash").getAsString().equals(originalHash))manifest=cached.getAsJsonObject("manifest");
else {
JsonObject capacity=api("/contributions/"+targetMap,null),bounds=capacity.getAsJsonObject("bounds");
int room=Math.min(12,Math.min(capacity.get("remaining").getAsInt(),capacity.get("playerRemaining").getAsInt()));
if(room<1)throw new IOException("This map or your player contribution limit is full. Wait for pending uploads to be reviewed or withdrawn.");
manifest.addProperty("contributionTo",targetMap);
// Export positions are already random. Keep a stable subset for resumable retries.
JsonArray subset=new JsonArray();for(JsonElement point:manifest.getAsJsonArray("viewpoints")){if(subset.size()==room)break;JsonObject p=point.getAsJsonObject();double x=p.get("x").getAsDouble(),z=p.get("z").getAsDouble();if(x>=bounds.get("minX").getAsDouble()&&x<=bounds.get("maxX").getAsDouble()&&z>=bounds.get("minZ").getAsDouble()&&z<=bounds.get("maxZ").getAsDouble())subset.add(point);}
if(subset.isEmpty())throw new IOException("Capture at least one panorama inside the original map's bounds, in the same world.");
manifest.add("viewpoints",subset);
if(manifest.get("source").getAsString().equals("fabric")){
Set<String> ids=new HashSet<>();for(JsonElement p:subset)ids.add(p.getAsJsonObject().get("id").getAsString());
JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets);
}
cached=new JsonObject();cached.addProperty("hash",originalHash);cached.add("manifest",manifest);save(selectionFile,cached);
}
}
progress.accept("Checking export files");
for(JsonElement element:manifest.getAsJsonArray("assets")){
checkCancelled(cancelled);var asset=element.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();
@@ -48,7 +75,7 @@ public final class UploadClient {
MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream in=Files.newInputStream(file)){byte[] buffer=new byte[65536];int n;while((n=in.read(buffer))!=-1){checkCancelled(cancelled);digest.update(buffer,0,n);}}
if(!HexFormat.of().formatHex(digest.digest()).equals(asset.get("sha256").getAsString()))throw new IOException("Export asset checksum changed: "+name+". Export again.");
}
Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
Path ledger=directory.resolve(targetMap==null?".upload-state.json":".upload-state-"+targetMap+".json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
String fingerprint=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
if(!state.has("hash")||!state.get("hash").getAsString().equals(fingerprint)){
state=new JsonObject();state.addProperty("hash",fingerprint);JsonObject body=new JsonObject();body.add("manifest",manifest);progress.accept("Creating private draft");checkCancelled(cancelled);

View File

@@ -1,6 +1,9 @@
website: 'http://localhost:3000'
token: ''
server-name: 'My Minecraft server'
# Public Minecraft connection address, matching the address used by Fabric players.
# Set this before exporting. A blank address cannot contribute to checked maps.
server-address: ''
max-area-size: 2048
chunks-per-tick: 1
viewpoints: 12
viewpoints: 24