Require distinct panorama locations for rounds and captures
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m30s

This commit is contained in:
2026-10-16 10:09:36 +05:00
parent c9fc9496eb
commit 1a34eb71c2
16 changed files with 367 additions and 3 deletions

View File

@@ -2,6 +2,13 @@
Maps hold 5–24 captured locations. Each game chooses five distinct locations in random order. The server plugin now picks random safe surface positions, with `viewpoints: 24` as the default for new installations; existing config files keep their value until edited and reloaded. The Fabric mod captures where the player is standing and permits up to 24 local captures; the uploader sends as many as the map and player limits allow. Maps hold 5–24 captured locations. Each game chooses five distinct locations in random order. The server plugin now picks random safe surface positions, with `viewpoints: 24` as the default for new installations; existing config files keep their value until edited and reloaded. The Fabric mod captures where the player is standing and permits up to 24 local captures; the uploader sends as many as the map and player limits allow.
Round selection treats captures from the same block position as one location,
even when their panorama IDs differ. Shared image paths are also selected only
once. This applies to solo and multiplayer games, including older published maps.
A map needs five distinct locations to start a game. The Fabric mod blocks
another capture at an already captured block position and prevents captures
while another capture or upload is in progress.
## Owner workflow ## Owner workflow
Open the map in the dashboard. Under **Panorama settings**, enable **Allow players to add panoramas with you**. This is off by default. Share the map ID and its Minecraft X/Z bounds. Players must capture in the same world and inside those bounds. Owners can add their own captures using that map ID even when player contributions are disabled. Open the map in the dashboard. Under **Panorama settings**, enable **Allow players to add panoramas with you**. This is off by default. Share the map ID and its Minecraft X/Z bounds. Players must capture in the same world and inside those bounds. Owners can add their own captures using that map ID even when player contributions are disabled.

View File

@@ -29,7 +29,7 @@ public final class MineGeoClient implements ClientModInitializer {
public String server(){var c=Minecraft.getInstance();return c.getCurrentServer()!=null?c.getCurrentServer().ip:"Singleplayer";} public String server(){var c=Minecraft.getInstance();return c.getCurrentServer()!=null?c.getCurrentServer().ip:"Singleplayer";}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
public void message(String text){status=text;var c=Minecraft.getInstance();if(c.player!=null)ClientCompat.message(c,Component.literal("[MineGeo] "+text));} public void message(String text){status=text;var c=Minecraft.getInstance();if(c.player!=null)ClientCompat.message(c,Component.literal("[MineGeo] "+text));}
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=Minecraft.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=24;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());ClientCompat.screen(c,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(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=Minecraft.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=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");if(dataset.hasLocation(c.player.getX(),c.player.getEyeY(),c.player.getZ()))throw new IllegalStateException("This location is already captured. Move to another block first.");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());ClientCompat.screen(c,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 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 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("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();Minecraft.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getPlatform().openUri(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();Minecraft.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){Minecraft.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();Minecraft.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getPlatform().openUri(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();Minecraft.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){Minecraft.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}

View File

@@ -20,6 +20,7 @@ public final class SurfaceDataset {
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);} private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
public static SurfaceDataset create(String server)throws Exception{var c=Minecraft.getInstance();var root=c.gameDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;} public static SurfaceDataset create(String server)throws Exception{var c=Minecraft.getInstance();var root=c.gameDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;}
public static SurfaceDataset open(String server)throws Exception{Path root=Minecraft.getInstance().gameDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);} public static SurfaceDataset open(String server)throws Exception{Path root=Minecraft.getInstance().gameDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
public boolean hasLocation(double x,double y,double z){return points.stream().anyMatch(p->Math.floor(p.x())==Math.floor(x)&&Math.floor(p.y())==Math.floor(y)&&Math.floor(p.z())==Math.floor(z));}
public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;} public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;}
public void scan(Minecraft c){ public void scan(Minecraft c){
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.level.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}} if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.level.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}

View File

@@ -29,7 +29,7 @@ public final class MineGeoClient implements ClientModInitializer {
public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";} 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("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();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 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.");int limit=24;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 capture(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");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=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");if(dataset.hasLocation(c.player.getX(),c.player.getEyeY(),c.player.getZ()))throw new IllegalStateException("This location is already captured. Move to another block first.");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 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 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("");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 pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");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()));}});}

View File

@@ -20,6 +20,7 @@ public final class SurfaceDataset {
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);} private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
public static SurfaceDataset create(String server)throws Exception{var c=MinecraftClient.getInstance();var root=c.runDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;} public static SurfaceDataset create(String server)throws Exception{var c=MinecraftClient.getInstance();var root=c.runDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;}
public static SurfaceDataset open(String server)throws Exception{Path root=MinecraftClient.getInstance().runDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);} public static SurfaceDataset open(String server)throws Exception{Path root=MinecraftClient.getInstance().runDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
public boolean hasLocation(double x,double y,double z){return points.stream().anyMatch(p->Math.floor(p.x())==Math.floor(x)&&Math.floor(p.y())==Math.floor(y)&&Math.floor(p.z())==Math.floor(z));}
public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;} public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;}
public void scan(MinecraftClient c){ public void scan(MinecraftClient c){
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.world.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}} if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.world.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}

View File

@@ -21,6 +21,7 @@
"test": "tsx --test tests/*.test.ts", "test": "tsx --test tests/*.test.ts",
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs", "test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs", "test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
"test:renderer": "node scripts/test-renderer.mjs", "test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs", "render:map": "node scripts/render-map.mjs",
"test:production": "node scripts/test-production.mjs", "test:production": "node scripts/test-production.mjs",

View File

@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
// Real game-creation functions, isolated from production sessions and scores.
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage;
create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
const owner = (
await db.query(
"insert into auth.users values(gen_random_uuid()) returning id",
)
).rows[0].id;
const map = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values($1,'Repeated capture fixture','fabric','{"minX":-64,"minZ":-64,"maxX":64,"maxZ":64}','published',17) returning id`,
[owner],
)
).rows[0].id;
const points = Array.from({ length: 10 }, (_, i) => ({
id: `p${i}`,
x: i - 5,
y: 65,
z: i - 5,
}));
// Reproduce the live map: unique IDs, but seven captures repeat a position.
const manifest = {
viewpoints: [
...points,
...points.slice(0, 7).map((p, i) => ({ ...p, id: `repeat${i}` })),
],
};
const revision = (
await db.query(
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
[map, JSON.stringify(manifest)],
)
).rows[0].id;
await db.query("update public.datasets set revision_id=$1 where id=$2", [
revision,
map,
]);
const pool = async (viewpoints) =>
(
await db.query("select public.game_viewpoint_pool($1,'fixture') pool", [
JSON.stringify({ viewpoints }),
])
).rows[0].pool;
assert.equal((await pool(manifest.viewpoints)).length, 10);
assert.equal(
(
await pool([
{ id: "a", x: -0.1, y: 65, z: 0 },
{ id: "b", x: -0.9, y: 65.8, z: 0.8 },
])
).length,
1,
);
assert.equal(
(
await pool([
{ id: "a", x: 0, y: 65, z: 0 },
{ id: "b", x: 4, y: 65, z: 4, assetId: "a" },
])
).length,
1,
);
assert.equal(
(
await pool([
{ id: "a", x: 0, y: 65, z: 0 },
{ id: "b", x: 4, y: 65, z: 4, assetId: "a", assetRoot: owner },
])
).length,
2,
);
for (let i = 0; i < 30; i++) {
const solo = (
await db.query("select public.create_game($1,'normal',$2,$3) id", [
map,
owner,
String(i).padStart(64, "0"),
])
).rows[0].id;
const multi = (
await db.query(
"select public.create_map_room($1,'hard','Host',2,$2,$3) id",
[map, String(i).padStart(64, "0"), owner],
)
).rows[0].id;
for (const [table, column, id] of [
["game_rounds", "session_id", solo],
["multiplayer_rounds", "room_id", multi],
]) {
const rounds = (
await db.query(
`select round_number,viewpoint from public.${table} where ${column}=$1 order by round_number`,
[id],
)
).rows;
assert.deepEqual(
rounds.map((r) => r.round_number),
[0, 1, 2, 3, 4],
);
assert.equal(
new Set(
rounds.map((r) =>
[r.viewpoint.x, r.viewpoint.y, r.viewpoint.z]
.map(Math.floor)
.join(","),
),
).size,
5,
);
assert.equal(
new Set(
rounds.map(
(r) =>
`${r.viewpoint.assetRoot ?? "fixture"}/${r.viewpoint.assetId ?? r.viewpoint.id}`,
),
).size,
5,
);
}
}
const countBefore = (
await db.query("select count(*)::int n from public.game_sessions")
).rows[0].n;
const insufficient = (
await db.query(
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
[
map,
JSON.stringify({
viewpoints: [...points.slice(0, 4), { ...points[0], id: "repeat" }],
}),
],
)
).rows[0].id;
await db.query("update public.datasets set revision_id=$1 where id=$2", [
insufficient,
map,
]);
await assert.rejects(
db.query("select public.create_game($1,'normal',$2,$3)", [
map,
owner,
"0".repeat(64),
]),
/five distinct/,
);
await assert.rejects(
db.query("select public.create_map_room($1,'normal','Host',2,$2,$3)", [
map,
"0".repeat(64),
owner,
]),
/five distinct/,
);
assert.equal(
(await db.query("select count(*)::int n from public.game_sessions")).rows[0]
.n,
countBefore,
);
assert.equal(
(
await db.query(
"select has_function_privilege('anon','public.game_viewpoint_pool(jsonb,text)','EXECUTE') allowed",
)
).rows[0].allowed,
false,
);
assert.equal(
(
await db.query(
"select has_function_privilege('service_role','public.game_viewpoint_pool(jsonb,text)','EXECUTE') allowed",
)
).rows[0].allowed,
true,
);
console.log(
"Round panorama SQL regressions passed: 30 solo games, 30 multiplayer rooms, duplicate positions/media, insufficient locations and permissions.",
);
} finally {
await db.close();
}

View File

@@ -21,6 +21,13 @@ export const pointSchema = z.object({
z: z.number().finite(), z: z.number().finite(),
}); });
export const faces = ["px", "nx", "py", "ny", "pz", "nz"] as const; export const faces = ["px", "nx", "py", "ny", "pz", "nz"] as const;
export function panoramaLocationKey(point: {
x: number;
y: number;
z: number;
}) {
return [point.x, point.y, point.z].map(Math.floor).join(",");
}
export const viewpointSchema = pointSchema.extend({ export const viewpointSchema = pointSchema.extend({
id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/), id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
assetRoot: z.string().uuid().optional(), assetRoot: z.string().uuid().optional(),
@@ -144,6 +151,11 @@ export const datasetSchema = z
// Media provenance is assigned by the server after the owner accepts a contribution. // Media provenance is assigned by the server after the owner accepts a contribution.
export function parseUploadManifest(value: unknown) { export function parseUploadManifest(value: unknown) {
const manifest = datasetSchema.parse(value); const manifest = datasetSchema.parse(value);
if (
!manifest.contributionTo &&
new Set(manifest.viewpoints.map(panoramaLocationKey)).size < 5
)
throw new Error("Capture at least five panoramas from different block positions");
if (manifest.mediaFormat) if (manifest.mediaFormat)
throw new Error("Use map import for rendered archives"); throw new Error("Use map import for rendered archives");
manifest.serverAddress = manifest.serverAddress =

View File

@@ -0,0 +1,97 @@
-- Capture IDs are unique, but multiple captures can still share a camera
-- position or the same six image files. Deduplicate before sampling rounds.
create or replace function public.game_viewpoint_pool(p_manifest jsonb, p_root text)
returns jsonb language sql immutable security invoker set search_path = '' as $$
with by_location as (
select distinct on (
floor((p->>'x')::numeric), floor((p->>'y')::numeric), floor((p->>'z')::numeric)
) p, n
from jsonb_array_elements(p_manifest->'viewpoints') with ordinality points(p, n)
order by floor((p->>'x')::numeric), floor((p->>'y')::numeric),
floor((p->>'z')::numeric), n
), by_media as (
select distinct on (
coalesce(p->>'assetRoot', p_root), coalesce(p->>'assetId', p->>'id')
) p, n from by_location
order by coalesce(p->>'assetRoot', p_root), coalesce(p->>'assetId', p->>'id'), n
)
select coalesce(jsonb_agg(p order by n), '[]'::jsonb) from by_media;
$$;
revoke all on function public.game_viewpoint_pool(jsonb, text)
from public, anon, authenticated;
grant execute on function public.game_viewpoint_pool(jsonb, text) to service_role;
create or replace function public.create_game(
p_dataset uuid, p_difficulty text, p_owner uuid, p_access_hash text
) returns uuid language plpgsql security invoker set search_path = '' as $$
declare
sid uuid;
revision uuid;
rev public.revisions;
vp jsonb;
locations jsonb;
n integer := 0;
settings jsonb;
begin
settings := public.resolve_difficulty_preset(p_difficulty);
revision := public.playable_map_revision(p_dataset, p_owner);
select * into rev from public.revisions where id = revision;
if not found then raise exception 'Map unavailable'; end if;
locations := public.game_viewpoint_pool(rev.manifest, rev.asset_root);
if jsonb_array_length(locations) < 5 then
raise exception 'Map needs at least five distinct panorama locations';
end if;
insert into public.game_sessions(
revision_id, owner_id, access_hash, difficulty, difficulty_settings
) values (rev.id, p_owner, p_access_hash, p_difficulty, settings)
returning id into sid;
for vp in select value from jsonb_array_elements(locations) order by random() limit 5
loop
insert into public.game_rounds(session_id, round_number, viewpoint)
values (sid, n, vp);
n := n + 1;
end loop;
return sid;
end;
$$;
create or replace function public.create_map_room(
p_dataset uuid, p_difficulty text, p_name text, p_limit integer,
p_access_hash text, p_owner uuid
) returns uuid language plpgsql security invoker set search_path = '' as $$
declare
rid uuid;
revision uuid;
pid uuid := gen_random_uuid();
rev public.revisions;
vp jsonb;
locations jsonb;
n integer := 0;
settings jsonb;
begin
if p_limit is null or p_limit not between 2 and 16 then
raise exception 'Choose between 2 and 16 players';
end if;
settings := public.resolve_difficulty_preset(p_difficulty);
revision := public.playable_map_revision(p_dataset, p_owner);
select * into rev from public.revisions where id = revision;
if not found then raise exception 'Map unavailable'; end if;
locations := public.game_viewpoint_pool(rev.manifest, rev.asset_root);
if jsonb_array_length(locations) < 5 then
raise exception 'Map needs at least five distinct panorama locations';
end if;
insert into public.multiplayer_rooms(
revision_id, host_player_id, difficulty, difficulty_settings, player_limit
) values (rev.id, pid, p_difficulty, settings, p_limit)
returning id into rid;
insert into public.multiplayer_players(id, room_id, access_hash, display_name)
values (pid, rid, p_access_hash, btrim(p_name));
for vp in select value from jsonb_array_elements(locations) order by random() limit 5
loop
insert into public.multiplayer_rounds(room_id, round_number, viewpoint)
values (rid, n, vp);
n := n + 1;
end loop;
return rid;
end;
$$;

View File

@@ -1,7 +1,53 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { boundsSchema, datasetSchema, scoreGuess } from "../shared/protocol"; import {
boundsSchema,
datasetSchema,
parseUploadManifest,
scoreGuess,
} from "../shared/protocol";
const bounds = { minX: -512, minZ: -256, maxX: 512, maxZ: 256 }; const bounds = { minX: -512, minZ: -256, maxX: 512, maxZ: 256 };
test("new maps need five distinct positions, while older captures can retain duplicates", () => {
const manifest = {
format: "minegeo-dataset/v1",
minecraftVersion: "1.21.11",
dimension: "minecraft:overworld",
source: "plugin",
name: "Distinct location fixture",
serverName: "Fixture",
north: "-z",
bounds,
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i - 4,
y: 65,
z: i - 4,
})),
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
};
assert.equal(parseUploadManifest(manifest).viewpoints.length, 5);
const repeated = {
...manifest,
viewpoints: [
...manifest.viewpoints,
{ id: "repeat", x: -3.9, y: 65.5, z: -3.9 },
],
};
// Keep older exports usable when they already contain five distinct locations.
assert.equal(datasetSchema.safeParse(repeated).success, true);
assert.equal(parseUploadManifest(repeated).viewpoints.length, 6);
assert.throws(
() =>
parseUploadManifest({
...manifest,
viewpoints: [
...manifest.viewpoints.slice(0, 4),
repeated.viewpoints[5],
],
}),
/five panoramas from different block positions/,
);
});
test("horizontal scoring ignores elevation and handles negative coordinates", () => { test("horizontal scoring ignores elevation and handles negative coordinates", () => {
assert.equal( assert.equal(
scoreGuess({ x: -13, z: -25 }, { x: -13, z: -25 }, bounds).score, scoreGuess({ x: -13, z: -25 }, { x: -13, z: -25 }, bounds).score,