Require distinct panorama locations for rounds and captures
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m30s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m30s
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 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 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 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()));}});}
|
||||
|
||||
@@ -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);}
|
||||
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 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 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;}}
|
||||
|
||||
@@ -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 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 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 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()));}});}
|
||||
|
||||
@@ -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);}
|
||||
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 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 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;}}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"test:leaderboard": "node scripts/test-leaderboard-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",
|
||||
"render:map": "node scripts/render-map.mjs",
|
||||
"test:production": "node scripts/test-production.mjs",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
199
scripts/test-round-panoramas-sql.mjs
Normal file
199
scripts/test-round-panoramas-sql.mjs
Normal 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();
|
||||
}
|
||||
@@ -21,6 +21,13 @@ export const pointSchema = z.object({
|
||||
z: z.number().finite(),
|
||||
});
|
||||
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({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
|
||||
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.
|
||||
export function parseUploadManifest(value: unknown) {
|
||||
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)
|
||||
throw new Error("Use map import for rendered archives");
|
||||
manifest.serverAddress =
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -1,7 +1,53 @@
|
||||
import { test } from "node:test";
|
||||
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 };
|
||||
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", () => {
|
||||
assert.equal(
|
||||
scoreGuess({ x: -13, z: -25 }, { x: -13, z: -25 }, bounds).score,
|
||||
|
||||
Reference in New Issue
Block a user