diff --git a/docs/panorama-contributions.md b/docs/panorama-contributions.md index 1aa5aa3..53ca3ce 100644 --- a/docs/panorama-contributions.md +++ b/docs/panorama-contributions.md @@ -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. diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java index 870c08b..6a12339 100644 --- a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java @@ -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()));}});} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java index be0d32f..0390e52 100644 --- a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java @@ -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=minZ&&z=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()));}});} diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java index 1ab53bc..1c64a9d 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java @@ -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=minZ&&z 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(); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index 69aa4b5..28217de 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -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 = diff --git a/supabase/migrations/20261016050401_distinct_round_panoramas.sql b/supabase/migrations/20261016050401_distinct_round_panoramas.sql new file mode 100644 index 0000000..7527831 --- /dev/null +++ b/supabase/migrations/20261016050401_distinct_round_panoramas.sql @@ -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; +$$; diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index c22e55e..688e4a9 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -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,