The first commit, the core and all files is adding to repo

This commit is contained in:
2026-10-04 14:20:13 +05:00
commit 53d7b263c4
94 changed files with 10668 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
plugins { id 'java' }
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
dependencies { compileOnly 'org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT'; implementation 'com.google.code.gson:gson:2.11.0' }
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
jar { archiveFileName = 'minegeo-plugin-1.21.11.jar'; from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }; duplicatesStrategy = DuplicatesStrategy.EXCLUDE }

View File

@@ -0,0 +1,73 @@
package dev.minegeo.plugin;
import com.google.gson.*;
import org.bukkit.*;
import org.bukkit.command.*;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.GZIPOutputStream;
public final class MineGeoPlugin extends JavaPlugin {
private World selectedWorld;
private int minX=-256,minZ=-256,maxX=256,maxZ=256;
private final AtomicBoolean cancelled=new AtomicBoolean();
private volatile String status="Idle";
private volatile boolean busy;
private final Gson gson=new Gson();
@Override public void onEnable(){saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);getLogger().info("MineGeo ready. /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");}
@Override public void onDisable(){cancelled.set(true);}
@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){sender.sendMessage("MineGeo: area, export, status, cancel, link, upload");return true;}
switch(args[0].toLowerCase(Locale.ROOT)){
case "area" -> { if(busy)throw new IllegalArgumentException("Cancel the current export before changing the area.");if(args.length!=6)throw new IllegalArgumentException("area <world> <minX> <minZ> <maxX> <maxZ>");World w=Bukkit.getWorld(args[1]);if(w==null||w.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.min(2048,getConfig().getInt("max-area-size",2048));if(bx<=ax||bz<=az||bx-ax>cap||bz-az>cap)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side.");selectedWorld=w;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+w.getName()+" "+ax+","+az+" to "+bx+","+bz);}
case "status" -> sender.sendMessage("MineGeo: "+status);
case "cancel" -> {cancelled.set(true);sender.sendMessage("MineGeo cancellation requested.");}
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
case "link" -> { if(args.length!=2)throw new IllegalArgumentException("link <account token from MineGeo dashboard>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved. Restrict access to config.yml.");}
case "upload" -> {if(busy)throw new IllegalArgumentException("Wait for the current operation.");busy=true;Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{status="Uploading";new dev.minegeo.plugin.UploadClient(getConfig().getString("website"),getConfig().getString("token")).upload(getDataFolder().toPath().resolve("export"));status="Upload complete; review the private draft on MineGeo.";}catch(Exception ex){status="Upload failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}});sender.sendMessage("MineGeo upload started.");}
default -> throw new IllegalArgumentException("Unknown command.");
}
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}
return true;
}
private record Captured(int cx,int cz,ChunkSnapshot snapshot){}
private void export(CommandSender sender,String name)throws IOException{
if(busy)throw new IllegalArgumentException("An export or upload is already running.");
World world=selectedWorld!=null?selectedWorld:Bukkit.getWorlds().getFirst();
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only.");
busy=true;cancelled.set(false);List<int[]> coords=new ArrayList<>();
for(int cz=Math.floorDiv(minZ,16);cz<=Math.floorDiv(maxZ-1,16);cz++)for(int cx=Math.floorDiv(minX,16);cx<=Math.floorDiv(maxX-1,16);cx++)if(world.isChunkGenerated(cx,cz))coords.add(new int[]{cx,cz});
if(coords.isEmpty()){busy=false;throw new IllegalArgumentException("No generated chunks in the selected area.");}
Path out=getDataFolder().toPath().resolve("export");Files.createDirectories(out);
List<Captured> captured=new ArrayList<>();int lower=world.getMinHeight(),upper=world.getMaxHeight();
sender.sendMessage("MineGeo snapshotting "+coords.size()+" generated chunks. No terrain will be generated.");
new BukkitRunnable(){int index;
@Override public void run(){try{
if(cancelled.get()){status="Cancelled";busy=false;cancel();return;}
int batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
for(int n=0;n<batch&&index<coords.size();n++,index++){int[] c=coords.get(index);if(!world.loadChunk(c[0],c[1],false))continue;Chunk chunk=world.getChunkAt(c[0],c[1]);captured.add(new Captured(c[0],c[1],chunk.getChunkSnapshot(true,true,false)));}
status="Snapshotting "+index+"/"+coords.size();
if(index>=coords.size()){cancel();status="Encoding terrain";Bukkit.getScheduler().runTaskAsynchronously(MineGeoPlugin.this,()->encode(captured,out,name,lower,upper));}
}catch(Exception ex){cancel();busy=false;status="Export failed: "+ex.getMessage();getLogger().warning(status);}}
}.runTaskTimer(this,1,1);
}
private void encode(List<Captured> chunks,Path out,String name,int lower,int upper){
try{JsonObject bounds=new JsonObject();bounds.addProperty("minX",minX);bounds.addProperty("minZ",minZ);bounds.addProperty("maxX",maxX);bounds.addProperty("maxZ",maxZ);
JsonObject terrain=new JsonObject();terrain.add("bounds",bounds);JsonArray blocks=new JsonArray(),coverage=new JsonArray(),points=new JsonArray();
List<int[]> candidates=new ArrayList<>();
for(Captured c:chunks){if(cancelled.get())throw new IOException("Cancelled");JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
for(int z=0;z<16;z++)for(int x=0;x<16;x++){int wx=c.cx*16+x,wz=c.cz*16+z;if(wx<minX||wx>=maxX||wz<minZ||wz>=maxZ)continue;int top=c.snapshot.getHighestBlockYAt(x,z);for(int y=Math.max(lower,top-24);y<=Math.min(upper-1,top+1);y++){var bd=c.snapshot.getBlockData(x,y,z);if(bd.getMaterial().isAir())continue;JsonArray block=new JsonArray();block.add(wx);block.add(y);block.add(wz);block.add(bd.getAsString());block.add(c.snapshot.getBiome(x,y,z).getKey().toString());blocks.add(block);}if(x==8&&z==8){int y=top;while(y>lower&&!c.snapshot.getBlockData(x,y,z).getMaterial().isSolid())y--;Material ground=c.snapshot.getBlockData(x,y,z).getMaterial();if(ground.isSolid()&&!ground.name().contains("LEAVES")&&c.snapshot.getBlockData(x,y+1,z).getMaterial().isAir()&&c.snapshot.getBlockData(x,y+2,z).getMaterial().isAir())candidates.add(new int[]{wx,y+1,wz});}}
}
if(candidates.size()<5)throw new IOException("Need at least five safe surface viewpoints; choose a larger area.");
int desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12)));for(int i=0;i<Math.min(desired,candidates.size());i++){int[] p=candidates.get(i*candidates.size()/Math.min(desired,candidates.size()));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);}
terrain.add("blocks",blocks);terrain.add("coverage",coverage);Path tmp=out.resolve("terrain.json.gz.tmp");try(Writer writer=new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),java.nio.charset.StandardCharsets.UTF_8)){gson.toJson(terrain,writer);}Files.move(tmp,out.resolve("terrain.json.gz"),StandardCopyOption.REPLACE_EXISTING);
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 terrain exported from "+getConfig().getString("server-name"));manifest.addProperty("serverName",getConfig().getString("server-name"));manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);JsonArray assets=new JsonArray();byte[] bytes=Files.readAllBytes(out.resolve("terrain.json.gz"));JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",bytes.length);asset.addProperty("sha256",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)));assets.add(asset);manifest.add("assets",assets);Files.writeString(out.resolve("manifest.json"),gson.toJson(manifest));status="Export complete: "+blocks.size()+" blocks, "+points.size()+" viewpoints. Edit manifest viewpoints if needed; /minegeo upload.";getLogger().info(status);
}catch(Exception ex){status="Export failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}
}
}

View File

@@ -0,0 +1,35 @@
package dev.minegeo.plugin;
import com.google.gson.*;
import java.net.*;
import java.net.http.*;
import java.nio.file.*;
import java.io.*;
import java.util.*;
import java.time.Duration;
/** TUS resumable upload client shared by plugin and Fabric build. */
public final class UploadClient {
private final String website,token;
private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
private final Gson gson=new Gson();
public UploadClient(String website,String token){if(website==null||(!website.startsWith("https://")&&!website.startsWith("http://localhost")&&!website.startsWith("http://127.0.0.1")))throw new IllegalArgumentException("Use HTTPS or local development URL");this.website=website.replaceAll("/$","");this.token=token;}
private JsonObject api(String path,String body)throws Exception{var request=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Authorization","Bearer "+token).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();var response=client.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IOException("MineGeo API "+response.statusCode()+": "+response.body());return JsonParser.parseString(response.body()).getAsJsonObject();}
public JsonObject beginPairing()throws Exception{return api("/pairing","{}");}
public JsonObject pollPairing(String code)throws Exception{return api("/pairing/poll","{\"deviceCode\":\""+code+"\"}");}
public void upload(Path directory)throws Exception{
if(token==null||token.isBlank())throw new IOException("Link a contributor account first.");
JsonObject manifest=JsonParser.parseString(Files.readString(directory.resolve("manifest.json"))).getAsJsonObject();
Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
String manifestHash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(gson.toJson(manifest).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
if(!state.has("hash")||!state.get("hash").getAsString().equals(manifestHash)){state=new JsonObject();state.addProperty("hash",manifestHash);JsonObject request=new JsonObject();request.add("manifest",manifest);JsonObject created=api("/uploads",gson.toJson(request));state.addProperty("datasetId",created.get("datasetId").getAsString());Files.writeString(ledger,gson.toJson(state));}
String datasetId=state.get("datasetId").getAsString();
JsonObject request=new JsonObject();request.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(request));
for(JsonElement elem:signed.getAsJsonArray("assets")){JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory.normalize()))throw new IOException("Unsafe asset path");long length=Files.size(file);String signedToken=asset.get("token").getAsString();String endpoint=asset.get("endpoint").getAsString();String key="url:"+name;String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElse("0"));else uploadUrl=null;}
if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("TUS create failed: "+created.statusCode()+" "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);Files.writeString(ledger,gson.toJson(state));}
try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){while(offset<length){input.seek(offset);byte[] chunk=new byte[(int)Math.min(6*1024*1024,length-offset)];input.readFully(chunk);var patched=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofMinutes(3)).header("Tus-Resumable","1.0.0").header("Content-Type","application/offset+octet-stream").header("Upload-Offset",Long.toString(offset)).header("x-signature",signedToken).method("PATCH",HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),HttpResponse.BodyHandlers.ofString());if(patched.statusCode()!=204)throw new IOException("TUS patch failed at "+offset+": "+patched.statusCode()+". Run upload again to resume.");offset=Long.parseLong(patched.headers().firstValue("Upload-Offset").orElseThrow());}}
}
api("/uploads/finalize","{\"datasetId\":\""+datasetId+"\"}");
}
private String b64(String s){return Base64.getEncoder().encodeToString(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));}
}

View File

@@ -0,0 +1,6 @@
website: 'http://localhost:3000'
token: ''
server-name: 'My Minecraft server'
max-area-size: 2048
chunks-per-tick: 1
viewpoints: 12

View File

@@ -0,0 +1,12 @@
name: MineGeo
version: 0.1.0
main: dev.minegeo.plugin.MineGeoPlugin
api-version: '1.21'
description: Bounded terrain export for MineGeo panorama guessing
commands:
minegeo:
description: Link, select, export, upload, status or cancel MineGeo maps
permission: minegeo.admin
permissions:
minegeo.admin:
default: op