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

@@ -5,18 +5,19 @@ import net.minecraft.client.gui.widget.*;
import net.minecraft.text.Text;
public final class CaptureScreen extends Screen {
private TextFieldWidget website,name;
private TextFieldWidget website,name,target;
public CaptureScreen(){super(Text.literal("MineGeo Capture"));}
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130;
website=new TextFieldWidget(textRenderer,x,50,260,20,Text.literal("MineGeo website"));website.setMaxLength(200);website.setText(mod.website);addDrawableChild(website);
name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name);
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.capture();}).dimensions(x,126,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,126,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.pair();}).dimensions(x,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.upload(name.getText());}).dimensions(x+134,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,182,260,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,210,260,20).build());
target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target);
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build());
}
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
@Override public boolean shouldPause(){return false;}
}

View File

@@ -18,18 +18,19 @@ public final class MineGeoClient implements ClientModInitializer {
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
public volatile String targetMap="";
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
private Path config;
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
var key=KeyBindingHelper.registerKeyBinding(new KeyBinding("key.minegeo.open",InputUtil.Type.KEYSYM,GLFW.GLFW_KEY_G,KeyBinding.Category.create(Identifier.of("minegeo","capture"))));
ClientTickEvents.END_CLIENT_TICK.register(c->{while(key.wasPressed()){if(Capture.active())Capture.cancel("Capture cancelled");else c.setScreen(new CaptureScreen());}if(c.world!=null&&c.player!=null&&c.world.getRegistryKey().getValue().toString().equals("minecraft:overworld")){try{if(dataset==null||!dataset.server.equals(server()))dataset=SurfaceDataset.open(server());if(!Capture.active()&&!uploading)dataset.scan(c);}catch(Exception e){message("Map collection: "+e.getMessage());}}CaptureFixture.tick(c);});
}
public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);Files.writeString(config,gson.toJson(j));}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);}
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");if(dataset.points.size()>=100)throw new IllegalStateException("This map already contains 100 locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=targetMap.isBlank()?24:12;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
}

View File

@@ -29,6 +29,6 @@ public final class SurfaceDataset {
}
private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;}
private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());}
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion","1.21.11");j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",server);var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion","1.21.11");j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);}
}

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