diff --git a/MineGeo community improvement report.md b/MineGeo community improvement report.md new file mode 100644 index 0000000..e082fcc --- /dev/null +++ b/MineGeo community improvement report.md @@ -0,0 +1,37 @@ +# MineGeo community improvement report + +28 October 2026 • Review of the live homepage and Discord server + +Make it easy for new players to start a game, find support and discover community maps. The server already has English and Russian channels, a shared support selection, announcements and a server guide. + +## Changes made + +Updated the Discord server description to explain the game, community maps and bilingual support. Combined support selection into the existing language answers and removed the duplicate support question. Clarified the language and notification questions in English and Russian. Created the map-showcase forum with bilingual posting guidance and an optional onboarding answer. The English and Russian support channels now direct players to the bug button on the site. Sending messages and thread replies is denied to @everyone in both channels; administrator privileges and explicit role overrides can still affect access. + +## Recommended next steps + +- Make the first journey obvious: link the server guide to Play and maps, the field guide, official links and the site support button. Test it using a normal member account. + +- Encourage members to use the new map-showcase forum for one post per map, with a link, screenshots and spoiler warnings. Review its activity before adding more channels. + +- Run a small recurring community challenge: select one map, publish the deadline and rules, and invite players to share scores without revealing locations until the event ends. + +- Give players an optional map creator or event notification choice in onboarding, linked to a real channel or role. Keep notifications opt-in and use clear bilingual labels. + +- Continue applying English and Russian labels consistently across onboarding and the server guide. + +- Replace the image-only rules with accessible text in both languages and clarify how to report harassment or unsafe content. Keep the existing rules until the replacement is agreed. + +- Review the duplicate general and voice channels as membership grows; retain them for now and consolidate only after checking activity and existing content. + +- On the homepage, distinguish production maps from test content. The live page showed two published maps, including one labelled TEST, and two ranked players at review time. + +- Add a short first-game walkthrough and a prominent way to discover community maps. Explain browser and desktop options and link creators to the capture and publishing guide. + +- Test the support journey end to end in both languages, including mobile navigation, the bug button, submission confirmation and how users follow up. This review did not submit a real report. + +## Scope and limitations + +The site observations are from the live homepage, not a full functional or accessibility audit. Discord permissions were reviewed in the owner account; test with a regular member to confirm effective permissions. Existing channel history was preserved. + +Sources: https://minegeo.site/ • https://forum.minegeo.site/ • MineGeo Discord server diff --git a/MineGeo community report.docx b/MineGeo community report.docx new file mode 100644 index 0000000..7c4d15d Binary files /dev/null and b/MineGeo community report.docx differ diff --git a/MineGeo community report.txt b/MineGeo community report.txt new file mode 100644 index 0000000..b22895c --- /dev/null +++ b/MineGeo community report.txt @@ -0,0 +1,37 @@ +MineGeo community improvement report + +28 October 2026 • Review of the live homepage and Discord server + +Make it easy for new players to start a game, find support and discover community maps. The server already has English and Russian channels, a shared support selection, announcements and a server guide. + +Changes made + +Updated the Discord server description to explain the game, community maps and bilingual support. Combined support selection into the existing language answers and removed the duplicate support question. Clarified the language and notification questions in English and Russian. Created the map-showcase forum with bilingual posting guidance and an optional onboarding answer. The English and Russian support channels now direct players to the bug button on the site. Sending messages and thread replies is denied to @everyone in both channels; administrator privileges and explicit role overrides can still affect access. + +Recommended next steps + +Make the first journey obvious: link the server guide to Play and maps, the field guide, official links and the site support button. Test it using a normal member account. + +Encourage members to use the new map-showcase forum for one post per map, with a link, screenshots and spoiler warnings. Review its activity before adding more channels. + +Run a small recurring community challenge: select one map, publish the deadline and rules, and invite players to share scores without revealing locations until the event ends. + +Give players an optional map creator or event notification choice in onboarding, linked to a real channel or role. Keep notifications opt-in and use clear bilingual labels. + +Continue applying English and Russian labels consistently across onboarding and the server guide. + +Replace the image-only rules with accessible text in both languages and clarify how to report harassment or unsafe content. Keep the existing rules until the replacement is agreed. + +Review the duplicate general and voice channels as membership grows; retain them for now and consolidate only after checking activity and existing content. + +On the homepage, distinguish production maps from test content. The live page showed two published maps, including one labelled TEST, and two ranked players at review time. + +Add a short first-game walkthrough and a prominent way to discover community maps. Explain browser and desktop options and link creators to the capture and publishing guide. + +Test the support journey end to end in both languages, including mobile navigation, the bug button, submission confirmation and how users follow up. This review did not submit a real report. + +Scope and limitations + +The site observations are from the live homepage, not a full functional or accessibility audit. Discord permissions were reviewed in the owner account; test with a regular member to confirm effective permissions. Existing channel history was preserved. + +Sources: https://minegeo.site/ • https://forum.minegeo.site/ • MineGeo Discord server \ No newline at end of file diff --git a/MineGeo report sanitized.docx b/MineGeo report sanitized.docx new file mode 100644 index 0000000..851ff54 Binary files /dev/null and b/MineGeo report sanitized.docx differ diff --git a/app/downloads/page.tsx b/app/downloads/page.tsx index 449f734..3e1fccc 100644 --- a/app/downloads/page.tsx +++ b/app/downloads/page.tsx @@ -4,7 +4,7 @@ import { Blocks, Server, Download, ExternalLink } from "lucide-react"; import { existsSync } from "node:fs"; import Link from "next/link"; import desktopRelease from "@/shared/desktop-release.json"; -const release = "0.1.3"; +const release = "0.1.4"; const versions = ["1.21.11", "26.1", "26.2"]; export const dynamic = "force-dynamic"; export default async function Downloads() { diff --git a/components/ContributionInstructions.tsx b/components/ContributionInstructions.tsx index 94ecc3f..b1a56be 100644 --- a/components/ContributionInstructions.tsx +++ b/components/ContributionInstructions.tsx @@ -22,7 +22,7 @@ export function ContributionInstructions({ {serverAddress}

{t( - "Capture on this server. Uploads from a different address are blocked.", + "Capture using any listed address. Uploads from other addresses are blocked.", )}

diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx index d400403..822078b 100644 --- a/components/DraftEditor.tsx +++ b/components/DraftEditor.tsx @@ -17,6 +17,7 @@ import { ExportMap } from "./MapTransfer"; type Draft = { imported_from_id: string | null; imported_owner_name: string | null; + ownsPublishedParent: boolean; id: string; name: string; description: string; @@ -87,7 +88,7 @@ export function DraftEditor({ clearTimeout(timer); }; }, [id, version]); - async function save(submit = false) { + async function save(submit = false, publishOwnContribution = false) { setBusy(true); setError(""); setMessage(""); @@ -99,12 +100,25 @@ export function DraftEditor({ ); dirty.current = false; if (submit) { - await api("datasets/" + id + "/submit", {}); + const publishUpdate = + draft?.state === "published" && !draft.parent_dataset_id; + await api( + "datasets/" + + id + + (publishOwnContribution + ? "/publish-own-contribution" + : publishUpdate + ? "/publish-update" + : "/submit"), + {}, + ); setVersion((v) => v + 1); setMessage( - draft?.parent_dataset_id - ? "Submitted for map owner approval." - : "Submitted for moderator review.", + publishUpdate || publishOwnContribution + ? "Map update published." + : draft?.parent_dataset_id + ? "Submitted for map owner approval." + : "Submitted for moderator review.", ); } else setMessage("Draft saved."); } catch (e) { @@ -384,8 +398,19 @@ export function DraftEditor({ > {draft.parent_dataset_id ? t("Submit to map owner →") - : t("Submit for review →")} + : draft.state === "published" + ? t("Publish update →") + : t("Submit for review →")} + {draft.parent_dataset_id && draft.ownsPublishedParent && ( + + )} )} {!draft.imported_from_id && !draft.parent_dataset_id && ( diff --git a/components/OwnerContributions.tsx b/components/OwnerContributions.tsx index 40f38e9..f4382c3 100644 --- a/components/OwnerContributions.tsx +++ b/components/OwnerContributions.tsx @@ -140,11 +140,11 @@ export function OwnerContributions({ )}

{t( - "Use the Minecraft hostname or IP players connect to. This address is shown publicly when player contributions are enabled. The mod records the connected server; plugin admins set server-address before exporting. You can override the captured address here, or clear it to allow unchecked submissions.", + "Enter up to 10 Minecraft hostnames or IPs separated by commas. These addresses are public when contributions are enabled. Clear them to turn off the server check.", )}

diff --git a/discord-release-draft.md b/discord-release-draft.md new file mode 100644 index 0000000..1732d4e --- /dev/null +++ b/discord-release-draft.md @@ -0,0 +1,16 @@ +# MineGeo update — draft, not ready to publish + +The release is still under development. Verify each item before posting in +`___news___`. + +**English** + +Map creators can configure multiple server addresses for contributions, update +their own published maps more easily, and capture improved maps with the Fabric +mod. The mod also has a button that opens the MineGeo login page. + +**Русский** + +Создатели карт смогут указать несколько адресов сервера для добавлений, проще +обновлять свои опубликованные карты и создавать улучшенные карты через мод +Fabric. В моде также появилась кнопка для открытия страницы входа MineGeo. diff --git a/docs/panorama-contributions.md b/docs/panorama-contributions.md index 53ca3ce..c4a2859 100644 --- a/docs/panorama-contributions.md +++ b/docs/panorama-contributions.md @@ -21,7 +21,16 @@ For an already published map, its approved name, description, panorama count, an ## Refresh guessing map -**Map tools → Refresh guessing map** rebuilds only the overhead map through the worker. It consumes no panorama slots and leaves every panorama image unchanged. Choose the original capture, another processed capture you own, or an accepted contribution whose bounds and expected server address match the map exactly. To show new buildings, export and upload fresh terrain first. The published map and ongoing games keep their current image until a moderator approves the update. +**Map tools → Refresh guessing map** rebuilds only the overhead map through the worker. It consumes no panorama slots and leaves every panorama image unchanged. Choose the original capture, another processed capture you own, or an accepted contribution whose bounds and expected server address match the map exactly. To show new buildings, export and upload fresh terrain first. The published map and ongoing games keep their current image until the owner publishes the finished update; ongoing games retain their original revision. + +New Fabric captures also record up to four surface layers from chunks already +loaded by the client. The standalone worker renders those blocks using the same +overhead renderer as plugin terrain, while the mod's panorama photographs remain +unchanged. Use **Rescan terrain** and revisit changed areas before uploading; +previously captured chunks remain available until they are rescanned. It does not +request unexplored chunks; revisit existing areas to capture terrain for older mod maps. Earlier Fabric exports without terrain keep +using their captured flat map. A standalone worker is required to process the new +terrain exports; the image-only serverless processor leaves them queued for it. ## Player workflow @@ -36,6 +45,20 @@ Both tools can contribute to either kind of map. The original surface map and bo ## Contribution server check +Owners can enter up to ten connection addresses separated by commas, for example +`play.example.com, mc.example.com:25566`. Matching any listed address is enough; +the backend still checks every upload and retry. Older capture tools defer the +alias check to the website. The updated tools check before uploading as well. + +For a map that is already published, its owner can save the draft and select +**Publish update** to publish a validated revision without another moderator +review. A new map still requires its initial review. This also publishes any +player contributions the owner has already accepted into that draft. +When uploading the owner's own contribution to an already published map, the +contribution draft also offers **Update my map now**. It submits and accepts +the owner's selected panoramas and publishes the update together; it refuses to +run while the parent map has other unpublished edits. + Each map has a separate **Contribution server address** setting. New maps use the address recorded by their capture tool. Existing maps with a valid captured address are initialized from their own manifest. Maps whose old exports contain no address show **Server check is off** until the owner enters one. When contributions are enabled, the map page shows that address. The owner can correct it in the dashboard, including a custom port. **Clear address and turn off check** removes the displayed address and permits submissions without matching a server. Clearing does not skip owner approval or moderator review. diff --git a/docs/server-plugin.md b/docs/server-plugin.md index 8b739d3..b92dcb5 100644 --- a/docs/server-plugin.md +++ b/docs/server-plugin.md @@ -1,8 +1,8 @@ -# Server plugin 0.1.3 +# Server plugin 0.1.4 ## Update and connect -1. Stop the Minecraft server, replace its old MineGeo JAR with `public/tools/plugin/minegeo-plugin-0.1.3.jar`, and restart. Run `version MineGeo` in the console to verify 0.1.3. +1. Stop the Minecraft server, replace its old MineGeo JAR with `public/tools/plugin/minegeo-plugin-0.1.4.jar`, and restart. Run `version MineGeo` in the console to verify 0.1.4. 2. Open your MineGeo website dashboard, sign in, and generate a plugin token. 3. In the Minecraft server console, configure the Minecraft server address and contributor token. Console commands do not need the leading slash: diff --git a/docs/shared-assets.md b/docs/shared-assets.md index c8a5392..c2fc064 100644 --- a/docs/shared-assets.md +++ b/docs/shared-assets.md @@ -3,10 +3,10 @@ Commit these website assets together with the source code: - `public/images/example-map.webp` — homepage illustration and map-card fallback. -- `public/tools/mod/minegeo-fabric-1.21.11-0.1.3.jar` — Java 21 Fabric mod. -- `public/tools/mod/minegeo-fabric-26.1-0.1.3.jar` — Java 25 Fabric mod for 26.1. -- `public/tools/mod/minegeo-fabric-26.2-0.1.3.jar` — Java 25 Fabric mod for 26.2. -- `public/tools/plugin/minegeo-plugin-0.1.3.jar` — Bukkit/Paper plugin for all three. +- `public/tools/mod/minegeo-fabric-1.21.11-0.1.4.jar` — Java 21 Fabric mod. +- `public/tools/mod/minegeo-fabric-26.1-0.1.4.jar` — Java 25 Fabric mod for 26.1. +- `public/tools/mod/minegeo-fabric-26.2-0.1.4.jar` — Java 25 Fabric mod for 26.2. +- `public/tools/plugin/minegeo-plugin-0.1.4.jar` — Bukkit/Paper plugin for all three. Next.js serves these directly under `/images/` and `/tools/`. The download page uses versioned filenames to avoid stale downloads. Unversioned 1.21.11 URLs diff --git a/format_report_md.py b/format_report_md.py new file mode 100644 index 0000000..0620aab --- /dev/null +++ b/format_report_md.py @@ -0,0 +1,16 @@ +from pathlib import Path +p = Path('MineGeo community report.txt') +parts = p.read_text(encoding='utf-8').split('\n\n') +headings = {'Changes made', 'Recommended next steps', 'Scope and limitations'} +out = [] +in_recommendations = False +for i, part in enumerate(parts): + part = part.strip() + if not part: continue + if i == 0: out.append('# ' + part) + elif part in headings: + in_recommendations = part == 'Recommended next steps' + out.append('## ' + part) + elif in_recommendations: out.append('- ' + part) + else: out.append(part) +Path('MineGeo community improvement report.md').write_text('\n\n'.join(out) + '\n', encoding='utf-8') diff --git a/lib/api.ts b/lib/api.ts index caaaba2..bb39b97 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -193,12 +193,14 @@ export async function handle(request: NextRequest, path: string[]) { .order("name", { ascending: true }), ); return NextResponse.json({ - presets: presets.map(({ map_zoom, map_pan, timer_visible, ...preset }) => ({ - ...preset, - mapZoom: map_zoom, - mapPan: map_pan, - timerVisible: timer_visible, - })), + presets: presets.map( + ({ map_zoom, map_pan, timer_visible, ...preset }) => ({ + ...preset, + mapZoom: map_zoom, + mapPan: map_pan, + timerVisible: timer_visible, + }), + ), }); } if (method === "POST") { @@ -210,14 +212,26 @@ export async function handle(request: NextRequest, path: string[]) { const { mapZoom, mapPan, timerVisible, ...fields } = body; const { data, error } = await client .from("difficulty_presets") - .insert({ ...fields, map_zoom: mapZoom, map_pan: mapPan, timer_visible: timerVisible }) + .insert({ + ...fields, + map_zoom: mapZoom, + map_pan: mapPan, + timer_visible: timerVisible, + }) .select( "id,name,seconds,zoom,pan,compass,coordinates,map_zoom,map_pan,timer_visible,scoring", ) .single(); checkDifficultyPresetError(error); return NextResponse.json({ - preset: data ? { ...data, mapZoom: data.map_zoom, mapPan: data.map_pan, timerVisible: data.timer_visible } : null, + preset: data + ? { + ...data, + mapZoom: data.map_zoom, + mapPan: data.map_pan, + timerVisible: data.timer_visible, + } + : null, }); } } @@ -249,7 +263,10 @@ export async function handle(request: NextRequest, path: string[]) { const body = z .object({ datasetId: idSchema, - difficulty: z.union([z.enum(["easy", "normal", "hard", "void"]), idSchema]), + difficulty: z.union([ + z.enum(["easy", "normal", "hard", "void"]), + idSchema, + ]), }) .parse(await jsonBody(request)); const secret = cookieSecret(request) || randomBytes(32).toString("hex"); @@ -668,9 +685,22 @@ export async function handle(request: NextRequest, path: string[]) { .eq("dataset_id", d.id) .maybeSingle(); checkServiceError(jobResult.error); + const parent = d.parent_dataset_id + ? required( + await client + .from("datasets") + .select("owner_id,state,update_state") + .eq("id", d.parent_dataset_id) + .single(), + ) + : null; if (needsProcessing(jobResult.data)) processUploadAfterResponse(d.id); return NextResponse.json({ ...d, + ownsPublishedParent: + !!parent && + parent.owner_id === d.owner_id && + parent.state === "published", upload_jobs: jobResult.data, name: manifest.name, description: manifest.description, @@ -696,7 +726,7 @@ export async function handle(request: NextRequest, path: string[]) { allowContributions: z.boolean().optional(), contributionServerAddress: z .string() - .max(255) + .max(2559) .nullable() .optional(), }) @@ -738,6 +768,34 @@ export async function handle(request: NextRequest, path: string[]) { checkServiceError(error); return NextResponse.json({ ok: true }); } + if (method === "POST" && path[2] === "publish-own-contribution") { + const owner = await contributor(request, true); + if (owner !== d.owner_id) + throw new ServiceError("Map owner permission required", 403); + checkServiceError( + ( + await client.rpc("publish_own_contribution", { + p_contribution: d.id, + p_owner: owner, + }) + ).error, + ); + return NextResponse.json({ ok: true }); + } + if (method === "POST" && path[2] === "publish-update") { + const owner = await contributor(request, true); + if (owner !== d.owner_id) + throw new ServiceError("Map owner permission required", 403); + checkServiceError( + ( + await client.rpc("publish_owner_update", { + p_dataset: d.id, + p_owner: owner, + }) + ).error, + ); + return NextResponse.json({ ok: true }); + } if (method === "POST" && path[2] === "submit") { if (d.imported_from_id) throw new ServiceError( diff --git a/lib/fabric-jobs.ts b/lib/fabric-jobs.ts index 1ed6d11..f698ab7 100644 --- a/lib/fabric-jobs.ts +++ b/lib/fabric-jobs.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "../shared/database"; -import { datasetSchema } from "../shared/protocol"; +import { datasetSchema, usesTerrainRenderer } from "../shared/protocol"; import { processFabricImages } from "./fabric-images"; type Job = Database["public"]["Tables"]["upload_jobs"]["Row"]; @@ -63,7 +63,7 @@ export async function processFabricJob( .single(), ); const manifest = datasetSchema.parse(input.manifest); - if (dataset.source !== "fabric" && !manifest.mediaFormat) return "skipped"; + if (usesTerrainRenderer(manifest)) return "skipped"; const worker = "vercel-" + randomUUID(); const now = Date.now(); let claim = client diff --git a/minecraft/build.gradle b/minecraft/build.gradle index abb2dd7..6d4f40a 100644 --- a/minecraft/build.gradle +++ b/minecraft/build.gradle @@ -1,4 +1,4 @@ -allprojects { group = 'dev.minegeo'; version = '0.1.3'; repositories { mavenCentral() } } +allprojects { group = 'dev.minegeo'; version = '0.1.4'; repositories { mavenCentral() } } if (System.getenv('MINEGEO_JAVA_BUILD_ROOT')) { subprojects { layout.buildDirectory = new File(System.getenv('MINEGEO_JAVA_BUILD_ROOT'), project.name) } } diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java index 9e8a747..9707a85 100644 --- a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java @@ -8,13 +8,15 @@ public final class CaptureScreen extends Screen { private EditBox name,target; public CaptureScreen(){super(Component.literal("MineGeo Capture"));} @Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130; + addRenderableWidget(Button.builder(Component.literal("Log in"),b->net.minecraft.util.Util.getPlatform().openUri(MineGeoClient.website+"/log-in")).bounds(x+180,42,80,20).build()); name=new EditBox(font,x,92,260,20,Component.literal("Map name"));name.setMaxLength(80);name.setValue(mod.dataset==null?"My Minecraft map":mod.dataset.name);addRenderableWidget(name); target=new EditBox(font,x,126,260,20,Component.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setValue(mod.targetMap);addRenderableWidget(target); addRenderableWidget(Button.builder(Component.literal("Capture panorama"),b->{mod.targetMap=target.getValue().trim();mod.capture();}).bounds(x,154,126,20).build()); addRenderableWidget(Button.builder(Component.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getPlatform().openPath(mod.dataset.directory);}).bounds(x+134,154,126,20).build()); addRenderableWidget(Button.builder(Component.literal("Link account"),b->{mod.targetMap=target.getValue().trim();mod.pair();}).bounds(x,182,126,20).build()); addRenderableWidget(Button.builder(Component.literal("Upload / resume"),b->{mod.targetMap=target.getValue().trim();mod.upload(name.getValue());}).bounds(x+134,182,126,20).build()); - addRenderableWidget(Button.builder(Component.literal("New map at this location"),b->{mod.newMap();resize(width,height);}).bounds(x,210,260,20).build()); + addRenderableWidget(Button.builder(Component.literal("Rescan terrain"),b->mod.rescanTerrain()).bounds(x,210,126,20).build()); + addRenderableWidget(Button.builder(Component.literal("New map here"),b->{mod.newMap();resize(width,height);}).bounds(x+134,210,126,20).build()); addRenderableWidget(Button.builder(Component.literal("Close"),b->onClose()).bounds(x,238,260,20).build()); } @Override public void extractRenderState(GuiGraphicsExtractor context,int mouseX,int mouseY,float delta){super.extractRenderState(context,mouseX,mouseY,delta);int x=width/2-130;context.centeredText(font,title,width/2,20,0xff8cdf6b);context.text(font,"MineGeo service",x,38,0xffffffff);context.text(font,MineGeoClient.website,x,54,0xff8cdf6b);context.text(font,"Map name",x,80,0xffffffff);context.text(font,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.centeredText(font,Component.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:font.split(Component.literal(mod.status),260)){context.text(font,line,x,y,0xffd5d5d5);y+=10;}} 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 6a12339..4e5a271 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 @@ -32,6 +32,7 @@ public final class MineGeoClient implements ClientModInitializer { 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 rescanTerrain(){try{if(uploading||Capture.active()||dataset==null)throw new IllegalStateException("Join a world and finish the current operation first");dataset.rescan();message("Terrain rescan started. Revisit the area before uploading; only loaded chunks are captured.");}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 upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(Minecraft.getInstance().getCurrentServer()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size(){try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);Minecraft.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){Minecraft.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});} } 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 0390e52..c9203e2 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 @@ -14,22 +14,24 @@ import javax.imageio.ImageIO; public final class SurfaceDataset { public record Point(String id,double x,double y,double z){} public final Path directory;public final String server;public String name="My Minecraft map"; - public final List points=new ArrayList<>();public final Set coverage=new HashSet<>(); + public final List points=new ArrayList<>();public final Set coverage=new HashSet<>();private final Set refreshPending=new HashSet<>(); private final BufferedImage image=new BufferedImage(512,512,BufferedImage.TYPE_INT_ARGB); - private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ; - 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 int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;private final dev.minegeo.plugin.SurfaceTerrain terrain; + private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;terrain=new dev.minegeo.plugin.SurfaceTerrain(directory);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 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.comparingLong((Path p)->p.resolve("local.json").toFile().lastModified()).thenComparing(Path::toString).reversed()).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"))if(d.terrain.has(k.getAsString()))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 void rescan(){refreshPending.clear();refreshPending.addAll(coverage);scanning=null;} public boolean contains(double x,double z){return x>=minX&&x=minZ&&z=c.level.getMinY();dy++){pos.set(x,y-dy,z);var state=c.level.getBlockState(pos);if(!state.isAir())terrain.add(x,y-dy,z,state.toString());}} + if(cursor==256){try{terrain.finish(scanning);coverage.add(scanning);refreshPending.remove(scanning);}catch(Exception e){MineGeoClient.INSTANCE.message("Terrain capture: "+e.getMessage());}scanning=null;} } private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;} private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());} - public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} + public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");if(terrain.export(coverage,minX,minZ))addAsset(assets,"terrain.json.gz");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);} } diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java index bc27ecf..7ec56d0 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java @@ -8,13 +8,15 @@ public final class CaptureScreen extends Screen { private TextFieldWidget name,target; public CaptureScreen(){super(Text.literal("MineGeo Capture"));} @Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130; + addDrawableChild(ButtonWidget.builder(Text.literal("Log in"),b->net.minecraft.util.Util.getOperatingSystem().open(MineGeoClient.website+"/log-in")).dimensions(x+180,42,80,20).build()); name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name); target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target); addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build()); - addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Rescan terrain"),b->mod.rescanTerrain()).dimensions(x,210,126,20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("New map here"),b->{mod.newMap();init(width,height);}).dimensions(x+134,210,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build()); } @Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"MineGeo service",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,MineGeoClient.website,x,54,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}} diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java index 51e119b..a14f6f2 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java @@ -32,6 +32,7 @@ public final class MineGeoClient implements ClientModInitializer { 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 rescanTerrain(){try{if(uploading||Capture.active()||dataset==null)throw new IllegalStateException("Join a world and finish the current operation first");dataset.rescan();message("Terrain rescan started. Revisit the area before uploading; only loaded chunks are captured.");}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 upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size(){try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});} } 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 1c64a9d..6c62f86 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java @@ -14,22 +14,24 @@ import javax.imageio.ImageIO; public final class SurfaceDataset { public record Point(String id,double x,double y,double z){} public final Path directory;public final String server;public String name="My Minecraft map"; - public final List points=new ArrayList<>();public final Set coverage=new HashSet<>(); + public final List points=new ArrayList<>();public final Set coverage=new HashSet<>();private final Set refreshPending=new HashSet<>(); private final BufferedImage image=new BufferedImage(512,512,BufferedImage.TYPE_INT_ARGB); - private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ; - 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 int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;private final dev.minegeo.plugin.SurfaceTerrain terrain; + private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;terrain=new dev.minegeo.plugin.SurfaceTerrain(directory);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 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.comparingLong((Path p)->p.resolve("local.json").toFile().lastModified()).thenComparing(Path::toString).reversed()).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"))if(d.terrain.has(k.getAsString()))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 void rescan(){refreshPending.clear();refreshPending.addAll(coverage);scanning=null;} public boolean contains(double x,double z){return x>=minX&&x=minZ&&z=c.world.getBottomY();dy++){pos.set(x,y-dy,z);var state=c.world.getBlockState(pos);if(!state.isAir())terrain.add(x,y-dy,z,state.toString());}} + if(cursor==256){try{terrain.finish(scanning);coverage.add(scanning);refreshPending.remove(scanning);}catch(Exception e){MineGeoClient.INSTANCE.message("Terrain capture: "+e.getMessage());}scanning=null;} } private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;} private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());} - public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} + public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");if(terrain.export(coverage,minX,minZ))addAsset(assets,"terrain.json.gz");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);} } diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/ServerAddress.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/ServerAddress.java index 5af4014..aadfb48 100644 --- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/ServerAddress.java +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/ServerAddress.java @@ -25,9 +25,9 @@ public final class ServerAddress { return host+(port==25565?"":":"+port); } public static void requireMatch(String expected,String captured) throws IOException { - String server=normalize(expected); - if(server==null)return; + if(expected==null||expected.isBlank())return; String actual=normalize(captured); - if(!server.equals(actual))throw new IOException("Wrong capture server: this map requires "+server+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again. Plugin admins: configure server-address before exporting. The map owner can correct or clear its server address in settings."); + for(String address:expected.split(","))if(java.util.Objects.equals(normalize(address),actual)&&actual!=null)return; + throw new IOException("Wrong capture server: this map requires one of "+expected+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again."); } } diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/SurfaceTerrain.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/SurfaceTerrain.java new file mode 100644 index 0000000..76fbe4b --- /dev/null +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/SurfaceTerrain.java @@ -0,0 +1,48 @@ +package dev.minegeo.plugin; + +import com.google.gson.*; +import com.google.gson.stream.JsonWriter; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; +import java.util.regex.Pattern; + +/** Persists bounded, client-visible surface samples in the plugin terrain format. */ +public final class SurfaceTerrain { + private static final Pattern BLOCK_STATE=Pattern.compile("minecraft:[a-z0-9_]+(\\[[a-z0-9_=,]+\\])?"); + private final Path directory; + private final Gson gson=new Gson(); + private JsonArray current; + public SurfaceTerrain(Path directory){this.directory=directory;} + private Path chunk(String key){return directory.resolve("terrain-chunks").resolve(key.replace(',','_')+".json.gz");} + public boolean has(String key){return Files.isRegularFile(chunk(key));} + public void begin(){current=new JsonArray();} + public void add(int x,int y,int z,String state){ + if(current==null)return; + state=state.replace("Block{","").replace("}",""); + if(!BLOCK_STATE.matcher(state).matches())return; + JsonArray block=new JsonArray();block.add(x);block.add(y);block.add(z);block.add(state);current.add(block); + } + public void finish(String key) throws IOException { + Path file=chunk(key);Files.createDirectories(file.getParent());Path tmp=file.resolveSibling(file.getFileName()+".tmp"); + try(Writer out=new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),StandardCharsets.UTF_8)){gson.toJson(current,out);} + Files.move(tmp,file,StandardCopyOption.REPLACE_EXISTING);current=null; + } + public boolean export(Set coverage,int minX,int minZ) throws IOException { + List keys=coverage.stream().filter(this::has).sorted().toList();if(keys.isEmpty())return false; + Path target=directory.resolve("terrain.json.gz"),tmp=directory.resolve("terrain.json.gz.tmp");int blocks=0; + try(JsonWriter writer=new JsonWriter(new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),StandardCharsets.UTF_8))){ + writer.beginObject().name("bounds").beginObject().name("minX").value(minX).name("minZ").value(minZ).name("maxX").value(minX+512).name("maxZ").value(minZ+512).endObject(); + writer.name("blocks").beginArray(); + for(String key:keys)try(Reader in=new InputStreamReader(new GZIPInputStream(Files.newInputStream(chunk(key))),StandardCharsets.UTF_8)){ + for(JsonElement block:JsonParser.parseReader(in).getAsJsonArray()){gson.toJson(block,writer);blocks++;} + } + writer.endArray().name("coverage").beginArray();for(String key:keys){String[] xy=key.split(",");writer.beginArray().value(Integer.parseInt(xy[0])).value(Integer.parseInt(xy[1])).endArray();}writer.endArray().endObject(); + } + if(blocks==0){Files.deleteIfExists(tmp);return false;} + if(Files.size(tmp)>50L*1024*1024){Files.deleteIfExists(tmp);throw new IOException("Terrain snapshot exceeds 50 MiB");} + Files.move(tmp,target,StandardCopyOption.REPLACE_EXISTING);return true; + } +} diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java index cfdc6bb..521d8c7 100644 --- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java @@ -48,7 +48,8 @@ public final class UploadClient { JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject(); if(targetMap!=null){ JsonObject serverInfo=api("/contributions/"+targetMap+"/server",null); - ServerAddress.requireMatch(serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null); + String expected=serverInfo.has("serverAddresses")?java.util.stream.StreamSupport.stream(serverInfo.getAsJsonArray("serverAddresses").spliterator(),false).map(e->e.getAsString()).collect(java.util.stream.Collectors.joining(",")):serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null; + ServerAddress.requireMatch(expected,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null); String originalHash=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8)); Path selectionFile=directory.resolve(".contribution-"+targetMap+".json"); JsonObject cached=Files.exists(selectionFile)?JsonParser.parseString(Files.readString(selectionFile)).getAsJsonObject():new JsonObject(); @@ -64,7 +65,7 @@ public final class UploadClient { manifest.add("viewpoints",subset); if(manifest.get("source").getAsString().equals("fabric")){ Set ids=new HashSet<>();for(JsonElement p:subset)ids.add(p.getAsJsonObject().get("id").getAsString()); - JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets); + JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||path.equals("terrain.json.gz")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets); } cached=new JsonObject();cached.addProperty("hash",originalHash);cached.add("manifest",manifest);save(selectionFile,cached); } diff --git a/minecraft/plugin/src/main/resources/plugin.yml b/minecraft/plugin/src/main/resources/plugin.yml index 849c2c7..d06f874 100644 --- a/minecraft/plugin/src/main/resources/plugin.yml +++ b/minecraft/plugin/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: MineGeo -version: 0.1.3 +version: 0.1.4 main: dev.minegeo.plugin.MineGeoPlugin api-version: '1.21' description: Bounded terrain export for MineGeo panorama guessing diff --git a/minegeo_report.py b/minegeo_report.py new file mode 100644 index 0000000..44a04fb --- /dev/null +++ b/minegeo_report.py @@ -0,0 +1,27 @@ +from docx import Document +from docx.shared import Pt + +d = Document() +d.add_heading('MineGeo community improvement report', 0) +d.add_paragraph('28 October 2026 • Review of the live homepage and Discord server') +d.add_paragraph('Make it easy for new players to start a game, find support and discover community maps. The server already has English and Russian channels, a shared support selection, announcements and a server guide.') +d.add_heading('Changes made', level=1) +d.add_paragraph('Updated the Discord server description to explain the game, community maps and bilingual support. The English and Russian support channels now direct players to the bug button on the site. Sending messages and thread replies is denied to @everyone in both channels; administrator privileges and explicit role overrides can still affect access.') +d.add_heading('Recommended next steps', level=1) +for x in [ + 'Make the first journey obvious: link the server guide to Play and maps, the field guide, official links and the site support button. Test it using a normal member account.', + 'Add a map showcase or feedback forum when there is enough activity. Encourage one post per map with a link, screenshots and a spoiler warning; avoid creating multiple empty channels now.', + 'Run a small recurring community challenge: select one map, publish the deadline and rules, and invite players to share scores without revealing locations until the event ends.', + 'Give players an optional map creator or event notification choice in onboarding, linked to a real channel or role. Keep notifications opt-in and use clear bilingual labels.', + 'Improve the onboarding wording: ask “Which languages do you speak?” and “Which notifications would you like?” Provide English and Russian labels consistently.', + 'Replace the image-only rules with accessible text in both languages and clarify how to report harassment or unsafe content. Keep the existing rules until the replacement is agreed.', + 'Review the duplicate general and voice channels as membership grows; retain them for now and consolidate only after checking activity and existing content.', + 'On the homepage, distinguish production maps from test content. The live page showed two published maps, including one labelled TEST, and two ranked players at review time.', + 'Add a short first-game walkthrough and a prominent way to discover community maps. Explain browser and desktop options and link creators to the capture and publishing guide.', + 'Test the support journey end to end in both languages, including mobile navigation, the bug button, submission confirmation and how users follow up. This review did not submit a real report.' +]: d.add_paragraph(x, style='List Bullet') +d.add_heading('Scope and limitations', level=1) +d.add_paragraph('The site observations are from the live homepage, not a full functional or accessibility audit. Discord permissions were reviewed in the owner account; test with a regular member to confirm effective permissions. Existing channel history was preserved.') +d.add_paragraph('Sources: https://minegeo.site/ • https://forum.minegeo.site/ • MineGeo Discord server') +d.styles['Normal'].font.size = Pt(10.5) +d.save('MineGeo community report.docx') diff --git a/public/tools/mod/minegeo-fabric-1.21.11-0.1.4.jar b/public/tools/mod/minegeo-fabric-1.21.11-0.1.4.jar new file mode 100644 index 0000000..680de44 Binary files /dev/null and b/public/tools/mod/minegeo-fabric-1.21.11-0.1.4.jar differ diff --git a/public/tools/mod/minegeo-fabric-1.21.11.jar b/public/tools/mod/minegeo-fabric-1.21.11.jar index 008ad32..680de44 100644 Binary files a/public/tools/mod/minegeo-fabric-1.21.11.jar and b/public/tools/mod/minegeo-fabric-1.21.11.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.1-0.1.4.jar b/public/tools/mod/minegeo-fabric-26.1-0.1.4.jar new file mode 100644 index 0000000..131194b Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.1-0.1.4.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.1.jar b/public/tools/mod/minegeo-fabric-26.1.jar index ee92ec9..131194b 100644 Binary files a/public/tools/mod/minegeo-fabric-26.1.jar and b/public/tools/mod/minegeo-fabric-26.1.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.2-0.1.4.jar b/public/tools/mod/minegeo-fabric-26.2-0.1.4.jar new file mode 100644 index 0000000..94afdd6 Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.2-0.1.4.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.2.jar b/public/tools/mod/minegeo-fabric-26.2.jar index 0b85ed0..94afdd6 100644 Binary files a/public/tools/mod/minegeo-fabric-26.2.jar and b/public/tools/mod/minegeo-fabric-26.2.jar differ diff --git a/public/tools/plugin/minegeo-plugin-0.1.4.jar b/public/tools/plugin/minegeo-plugin-0.1.4.jar new file mode 100644 index 0000000..bddf8f8 Binary files /dev/null and b/public/tools/plugin/minegeo-plugin-0.1.4.jar differ diff --git a/public/tools/plugin/minegeo-plugin-1.21.11.jar b/public/tools/plugin/minegeo-plugin-1.21.11.jar index 1032258..bddf8f8 100644 Binary files a/public/tools/plugin/minegeo-plugin-1.21.11.jar and b/public/tools/plugin/minegeo-plugin-1.21.11.jar differ diff --git a/scripts/test-account-controls-sql.mjs b/scripts/test-account-controls-sql.mjs index 206c51b..16dbcf2 100644 --- a/scripts/test-account-controls-sql.mjs +++ b/scripts/test-account-controls-sql.mjs @@ -16,7 +16,7 @@ try { await db.exec(`create role anon; create role authenticated; create role service_role bypassrls; create role supabase_auth_admin bypassrls; 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for(const file of (await readdir("supabase/migrations")).filter(f=>f.endsWith(".sql")).sort()) await db.exec(await readFile(`supabase/migrations/${file}`,"utf8")); await db.exec("grant select,insert,update,delete on all tables in schema public to service_role;"); @@ -61,8 +61,8 @@ try { await db.query("insert into public.contributor_tokens(owner_id,token_hash,label) values($1,'uploadfixture','fixture')",[owner]); await db.query("insert into public.pairing_codes(device_hash,user_code,owner_id,expires_at,token_value) values('device','PAIR',$1,clock_timestamp()+interval '1 day','secret')",[owner]); const key=await value("select public.developer_key_create($1,'Test',array['maps:read'],$2,'mg_dev_fixture',1) value",[owner,hash]); - await db.query("insert into public.game_sessions(id,revision_id,owner_id,access_hash,difficulty) values($1,$2,$3,'old-solo-secret','easy')",[game,revision,owner]); - await db.query("insert into public.multiplayer_rooms(id,revision_id,host_player_id,difficulty,player_limit) values($1,$2,$3,'easy',4)",[room,revision,player]); + await db.query("insert into public.game_sessions(id,revision_id,owner_id,access_hash,difficulty,difficulty_settings) values($1,$2,$3,'old-solo-secret','easy',public.resolve_difficulty_preset('easy'))",[game,revision,owner]); + await db.query("insert into public.multiplayer_rooms(id,revision_id,host_player_id,difficulty,player_limit,difficulty_settings) values($1,$2,$3,'easy',4,public.resolve_difficulty_preset('easy'))",[room,revision,player]); await db.query("insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id) values($1,$2,'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd','Fixture',$3)",[player,room,owner]); assert.deepEqual(await action(owner,"ban"),{id:owner,status:"banned",statusVersion:1}); assert.equal((await profile()).account_reason,reason); diff --git a/scripts/test-admin-permissions-sql.mjs b/scripts/test-admin-permissions-sql.mjs index bdcb955..a8a7d8a 100644 --- a/scripts/test-admin-permissions-sql.mjs +++ b/scripts/test-admin-permissions-sql.mjs @@ -7,7 +7,7 @@ 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for (const file of (await readdir("supabase/migrations")) .filter((f) => f.endsWith(".sql")) .sort()) diff --git a/scripts/test-bug-reports-sql.mjs b/scripts/test-bug-reports-sql.mjs index 7ca39c9..fbe4e4f 100644 --- a/scripts/test-bug-reports-sql.mjs +++ b/scripts/test-bug-reports-sql.mjs @@ -7,7 +7,7 @@ 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for (const file of (await readdir("supabase/migrations")) .filter((file) => file.endsWith(".sql")) .sort()) diff --git a/scripts/test-map-sharing-sql.mjs b/scripts/test-map-sharing-sql.mjs index ef495cc..f93d8af 100644 --- a/scripts/test-map-sharing-sql.mjs +++ b/scripts/test-map-sharing-sql.mjs @@ -5,7 +5,10 @@ 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); + create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for (const file of (await readdir("supabase/migrations")) .filter((f) => f.endsWith(".sql")) .sort()) diff --git a/scripts/test-map-update-sql.mjs b/scripts/test-map-update-sql.mjs new file mode 100644 index 0000000..5c870ed --- /dev/null +++ b/scripts/test-map-update-sql.mjs @@ -0,0 +1,56 @@ +import { readFile, readdir } from "node:fs/promises"; +import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js"; + +const db = new PGlite(); +try { + await db.exec(`create role anon; create role authenticated; create role service_role bypassrls; + 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 default gen_random_uuid(),bucket_id text,name text); + create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); + for (const file of (await readdir("supabase/migrations")) + .filter((f) => f.endsWith(".sql")) + .sort()) + await db.exec(await readFile(`supabase/migrations/${file}`, "utf8")); + await db.exec(`do $$ declare owner uuid:=gen_random_uuid(); other uuid:=gen_random_uuid(); root uuid; child uuid; m jsonb; old_revision uuid; begin + insert into auth.users(id) values(owner),(other); + insert into public.profiles(id,display_name) values(owner,'Owner'),(other,'Other') on conflict(id) do nothing; + m:=jsonb_build_object('name','Test map','description','test','source','plugin','serverName','Test server','serverAddress','one.example', + 'bounds',jsonb_build_object('minX',0,'minZ',0,'maxX',64,'maxZ',64), + 'viewpoints',(select jsonb_agg(jsonb_build_object('id','p'||n,'x',n,'y',65,'z',n)) from generate_series(1,5) n)); + root:=public.create_upload(owner,m); + perform public.set_contribution_server(root,owner,'ONE.example:25565, two.example:25566'); + perform public.assert_capture_server((select contribution_server_address from public.datasets where id=root),'two.example:25566'); + begin perform public.assert_capture_server((select contribution_server_address from public.datasets where id=root),'wrong.example'); raise exception 'Mismatch accepted'; + exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end; + update public.dataset_inputs set validated=true where dataset_id=root; + insert into public.revisions(dataset_id,manifest,asset_root) values(root,m,root::text) returning id into old_revision; + update public.datasets set state='published',revision_id=old_revision,update_state='draft' where id=root; + begin perform public.publish_owner_update(root,other); raise exception 'Non-owner accepted'; + exception when others then if sqlerrm <> 'Map owner permission required' then raise; end if; end; + perform public.publish_owner_update(root,owner); + if (select revision_id=old_revision or update_state is not null from public.datasets where id=root) then raise exception 'Owner update failed'; end if; + old_revision:=(select revision_id from public.datasets where id=root); + child:=public.create_upload(owner,m||jsonb_build_object('contributionTo',root,'serverAddress','two.example:25566','viewpoints',jsonb_build_array(jsonb_build_object('id','new','x',10,'y',65,'z',10)))); + update public.datasets set state='draft' where id=child; + update public.dataset_inputs set validated=true where dataset_id=child; + update public.datasets set update_state='draft' where id=root; + begin perform public.publish_own_contribution(child,owner); raise exception 'Unpublished changes were overwritten'; + exception when others then if sqlerrm <> 'Publish or discard the existing map draft first' then raise; end if; end; + update public.datasets set update_state=null where id=root; + perform public.publish_own_contribution(child,owner); + if (select revision_id=old_revision or panorama_count<>6 from public.datasets where id=root) then raise exception 'One-click contribution failed'; end if; + if (select contribution_status from public.datasets where id=child)<>'accepted' then raise exception 'Owner contribution was not accepted'; end if; + if has_function_privilege('authenticated','public.publish_owner_update(uuid,uuid)','execute') then raise exception 'Public RPC exposed'; end if; + end $$;`); + console.log( + "Map update SQL passed: aliases, mismatch rejection, owner authorization and immutable revision.", + ); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + await db.close(); +} diff --git a/scripts/test-minecraft-versions.mjs b/scripts/test-minecraft-versions.mjs index f629d40..a24a703 100644 --- a/scripts/test-minecraft-versions.mjs +++ b/scripts/test-minecraft-versions.mjs @@ -27,7 +27,7 @@ async function serverJar(version) { `https://fill.papermc.io/v3/projects/paper/versions/${version}/builds`, { headers: { - "User-Agent": "MineGeo/0.1.3 local compatibility verification", + "User-Agent": "MineGeo/0.1.4 local compatibility verification", }, }, ); @@ -120,7 +120,7 @@ for (const version of matrix) { await mkdir(`${directory}/plugins`, { recursive: true }); await copyFile(await serverJar(version), `${directory}/paper.jar`); await copyFile( - "public/tools/plugin/minegeo-plugin-0.1.3.jar", + "public/tools/plugin/minegeo-plugin-0.1.4.jar", `${directory}/plugins/minegeo.jar`, ); await writeFile(`${directory}/eula.txt`, "eula=true\n"); diff --git a/scripts/test-moderation-sql.mjs b/scripts/test-moderation-sql.mjs index c1de7ec..041f078 100644 --- a/scripts/test-moderation-sql.mjs +++ b/scripts/test-moderation-sql.mjs @@ -7,7 +7,7 @@ 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for (const file of (await readdir("supabase/migrations")) .filter((f) => f.endsWith(".sql")) .sort()) { diff --git a/scripts/test-plugin.ts b/scripts/test-plugin.ts index cfd3255..9fd41c8 100644 --- a/scripts/test-plugin.ts +++ b/scripts/test-plugin.ts @@ -110,7 +110,7 @@ await writeFile( // The deployed plugin always uses its hardcoded production endpoint. const execute = promisify(execFile); let exportDir: string; -const pluginJar = path.resolve("public/tools/plugin/minegeo-plugin-0.1.3.jar"); +const pluginJar = path.resolve("public/tools/plugin/minegeo-plugin-0.1.4.jar"); await execute("javac", [ "-cp", pluginJar, diff --git a/scripts/test-prometheus-sql.mjs b/scripts/test-prometheus-sql.mjs index 2a4e036..5cac3a2 100644 --- a/scripts/test-prometheus-sql.mjs +++ b/scripts/test-prometheus-sql.mjs @@ -11,7 +11,9 @@ try { await db.exec(`create role anon; create role authenticated; create role service_role bypassrls; 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);`); + 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 default gen_random_uuid(),bucket_id text,name text); + create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); for (const file of (await readdir("supabase/migrations")) .filter((f) => f.endsWith(".sql")) .sort()) @@ -44,8 +46,8 @@ try { ) ).rows[0].id; await db.query( - `insert into public.game_sessions(revision_id,access_hash,difficulty,current_round,created_at) - values($1,'test','easy',5,now()),($1,'test','hard',0,now()-interval '2 days')`, + `insert into public.game_sessions(revision_id,access_hash,difficulty,difficulty_settings,current_round,created_at) + values($1,'test','easy',public.resolve_difficulty_preset('easy'),5,now()),($1,'test','hard',public.resolve_difficulty_preset('hard'),0,now()-interval '2 days')`, [revision], ); const rpc = async (sql, args = []) => @@ -88,7 +90,7 @@ try { "select public.read_prometheus_metrics($1) value", [oldHash], ); - assert.equal(snapshot.length, 43); + assert.equal(snapshot.length, 45); // Includes complete/incomplete Void games. assert.equal(snapshot.find((s) => s.metric === "minegeo_accounts").value, 3); assert.equal( snapshot.find( @@ -183,6 +185,9 @@ try { await db.query("update public.profiles set role='admin' where id=$1", [ owner, ]); + await db.query("update public.profiles set account_status='deactivated' where id=$1", [owner]); + assert.equal(await rpc("select public.read_prometheus_metrics($1) value", [newHash]), null); + await db.query("update public.profiles set account_status='active' where id=$1", [owner]); await rpc("select public.configure_prometheus($1,null) value", [owner]); assert.equal( await rpc("select public.read_prometheus_metrics($1) value", [newHash]), diff --git a/scripts/test-round-panoramas-sql.mjs b/scripts/test-round-panoramas-sql.mjs index 1fff781..41723c5 100644 --- a/scripts/test-round-panoramas-sql.mjs +++ b/scripts/test-round-panoramas-sql.mjs @@ -22,6 +22,7 @@ try { "insert into auth.users values(gen_random_uuid()) returning id", ) ).rows[0].id; + await db.query("insert into public.profiles(id,display_name) values($1,'Host')", [owner]); const map = ( await db.query( `insert into public.datasets(owner_id,name,source,bounds,state,panorama_count) diff --git a/shared/database.ts b/shared/database.ts index 7190f13..50bc06a 100644 --- a/shared/database.ts +++ b/shared/database.ts @@ -1264,6 +1264,14 @@ export type Database = { }; Returns: undefined; }; + publish_owner_update: { + Args: { p_dataset: string; p_owner: string }; + Returns: undefined; + }; + publish_own_contribution: { + Args: { p_contribution: string; p_owner: string }; + Returns: undefined; + }; save_draft: { Args: { p_dataset: string; diff --git a/shared/locales/ru.json b/shared/locales/ru.json index f4054a5..1e9be9f 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -549,6 +549,12 @@ "Allow players to add panoramas with you": "Разрешить игрокам добавлять панорамы", "As the owner, you can fill the map’s 24 panorama slots.": "Как владелец, ты можешь заполнить все 24 слота панорам.", "Contribution server address": "Адрес сервера для добавлений", + "Contribution server addresses (separate with commas)": "Адреса серверов для добавлений (через запятую)", + "Enter up to 10 Minecraft hostnames or IPs separated by commas. These addresses are public when contributions are enabled. Clear them to turn off the server check.": "Укажи до 10 адресов Minecraft через запятую. При включённых добавлениях адреса видны всем. Очисти их, чтобы отключить проверку сервера.", + "Capture using any listed address. Uploads from other addresses are blocked.": "Снимай, подключившись по любому из указанных адресов. Загрузки с других адресов блокируются.", + "Publish update →": "Опубликовать обновление →", + "Update my map now →": "Обновить мою карту сейчас →", + "Map update published.": "Обновление карты опубликовано.", "Contribution server:": "Сервер для добавлений:", "Save server address": "Сохранить адрес сервера", "Server address saved.": "Адрес сервера сохранён.", diff --git a/shared/protocol.ts b/shared/protocol.ts index 369dc28..a23d381 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -177,6 +177,14 @@ export function parseUploadManifest(value: unknown) { return manifest; } export type DatasetManifest = z.infer; +/** Terrain exports must be rendered by the standalone worker. */ +export function usesTerrainRenderer(manifest: DatasetManifest) { + return ( + !manifest.mediaFormat && + (manifest.source === "plugin" || + manifest.assets.some((a) => a.path === "terrain.json.gz")) + ); +} export type Bounds = z.infer; export type Point = z.infer; export const difficulties = { diff --git a/supabase/migrations/20261028140845_map_contribution_aliases_and_owner_publish.sql b/supabase/migrations/20261028140845_map_contribution_aliases_and_owner_publish.sql new file mode 100644 index 0000000..42b6bb2 --- /dev/null +++ b/supabase/migrations/20261028140845_map_contribution_aliases_and_owner_publish.sql @@ -0,0 +1,84 @@ +-- Keep the legacy column and API shape while allowing comma-separated aliases. +create or replace function public.assert_capture_server(p_expected text,p_actual text) returns void +language plpgsql security invoker set search_path='' as $$ +declare expected text; actual text; allowed text[] := '{}'; +begin + if nullif(trim(p_expected),'') is null then return; end if; + foreach expected in array string_to_array(p_expected,',') loop + expected := public.normalize_server_address(expected); + if expected is null then raise exception 'Empty server address'; end if; + allowed := array_append(allowed,expected); + end loop; + actual := public.normalize_server_address(p_actual); + if actual is null or not (actual = any(allowed)) then + raise exception 'Wrong capture server: use one of %, but the export records %.', array_to_string(allowed, ', '), coalesce(actual,'no server address'); + end if; +end; $$; + +create or replace function public.set_contribution_server(p_dataset uuid,p_owner uuid,p_address text) returns void +language plpgsql security invoker set search_path='' as $$ +declare address text; addresses text[] := '{}'; +begin + perform 1 from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update; + if not found then raise exception 'Map owner permission required'; end if; + if nullif(trim(p_address),'') is not null then + foreach address in array string_to_array(p_address,',') loop + address := public.normalize_server_address(address); + if address is null then raise exception 'Empty server address'; end if; + if not address=any(addresses) then addresses:=array_append(addresses,address); end if; + end loop; + if cardinality(addresses)>10 then raise exception 'At most 10 server addresses are allowed'; end if; + end if; + update public.datasets set contribution_server_address=nullif(array_to_string(addresses,','),''),updated_at=now() where id=p_dataset; +end; $$; + +-- Legacy uploaders can defer alias validation to the server; updated uploaders +-- use the complete list before transferring files. +create or replace function public.contribution_server(p_dataset uuid,p_owner uuid) returns jsonb +language plpgsql security invoker set search_path='' as $$ +declare d public.datasets; addresses text[]; +begin + select * into d from public.datasets where id=p_dataset and parent_dataset_id is null; + if not found then raise exception 'Map not found'; end if; + if d.owner_id<>p_owner and not d.allow_contributions then raise exception 'The owner has disabled player contributions'; end if; + addresses:=case when d.contribution_server_address is null then '{}'::text[] else string_to_array(d.contribution_server_address,',') end; + return jsonb_build_object('serverAddress',case when cardinality(addresses)=1 then addresses[1] else null end,'serverAddresses',to_jsonb(addresses)); +end; $$; + +-- Only already approved, owned maps can publish a validated draft directly. +create function public.publish_owner_update(p_dataset uuid,p_owner uuid) returns void +language plpgsql security invoker set search_path='' as $$ +declare d public.datasets; m jsonb; rev uuid; first jsonb; n integer; +begin + select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update; + if not found or d.parent_dataset_id is not null or d.imported_from_id is not null then raise exception 'Map owner permission required'; end if; + if d.state <> 'published' or d.banned_at is not null or coalesce(d.update_state,'') not in ('draft','rejected') then raise exception 'Published map update is not ready'; end if; + if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'Wait for map rendering to finish'; end if; + select manifest into m from public.dataset_inputs where dataset_id=d.id and validated; + n:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0; + if m is null or n<5 or n>24 then raise exception 'Map requires 5–24 validated panoramas'; end if; + insert into public.revisions(dataset_id,manifest,asset_root) values(d.id,m,d.id::text) returning id into rev; + update public.datasets set revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=n,map_path=coalesce(m->>'mapPath',d.map_path), + cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=d.id; +end; $$; +revoke execute on function public.publish_owner_update(uuid,uuid) from public,anon,authenticated; +grant execute on function public.publish_owner_update(uuid,uuid) to service_role; + +-- Publish an owner's own processed contribution in one transaction. A map +-- with other draft edits must be reviewed separately by its owner first. +create function public.publish_own_contribution(p_contribution uuid,p_owner uuid) returns void +language plpgsql security invoker set search_path='' as $$ +declare child public.datasets; root public.datasets; +begin + select * into child from public.datasets where id=p_contribution and owner_id=p_owner; + if not found or child.parent_dataset_id is null then raise exception 'Owner contribution required'; end if; + select * into root from public.datasets where id=child.parent_dataset_id and owner_id=p_owner for update; + if not found or root.state<>'published' or root.update_state is not null or root.banned_at is not null or root.imported_from_id is not null then + raise exception 'Publish or discard the existing map draft first'; + end if; + perform public.submit_draft(child.id,p_owner); + perform public.review_contribution(root.id,child.id,p_owner,'accept'); + perform public.publish_owner_update(root.id,p_owner); +end; $$; +revoke execute on function public.publish_own_contribution(uuid,uuid) from public,anon,authenticated; +grant execute on function public.publish_own_contribution(uuid,uuid) to service_role; diff --git a/supabase/migrations/20261028143543_restore_metrics_account_status_check.sql b/supabase/migrations/20261028143543_restore_metrics_account_status_check.sql new file mode 100644 index 0000000..7c4dac3 --- /dev/null +++ b/supabase/migrations/20261028143543_restore_metrics_account_status_check.sql @@ -0,0 +1,15 @@ +-- The Void difficulty migration recreated this function without retaining the +-- active-account check added by account controls. +do $$ +declare definition text; +begin + definition := pg_get_functiondef('public.read_prometheus_metrics(text)'::regprocedure); + if position('p.role=''admin'' and p.account_status=''active''' in definition) > 0 then + return; + end if; + if position('p.role=''admin''' in definition) = 0 then + raise exception 'Metrics authorization definition is unexpected'; + end if; + definition := replace(definition, 'p.role=''admin''', 'p.role=''admin'' and p.account_status=''active'''); + execute definition; +end; $$; diff --git a/tests/terrain-routing.test.ts b/tests/terrain-routing.test.ts new file mode 100644 index 0000000..26818a5 --- /dev/null +++ b/tests/terrain-routing.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { datasetSchema, usesTerrainRenderer } from "../shared/protocol"; + +test("Fabric terrain uses the standalone renderer; older captures and imports keep image processing", () => { + const asset = (path: string) => ({ path, bytes: 1, sha256: "a".repeat(64) }); + const points = Array.from({ length: 5 }, (_, i) => ({ id: `p${i}`, x: i, y: 65, z: i })); + const common = { format: "minegeo-dataset/v1", minecraftVersion: "1.21.11", dimension: "minecraft:overworld", source: "fabric", name: "Example", serverName: "Example", north: "-z", bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 }, viewpoints: points }; + const faces = ["px", "nx", "py", "ny", "pz", "nz"]; + const pngAssets = [asset("map.png"), ...points.flatMap(p => faces.map(f => asset(`${p.id}/${f}.png`)))]; + const old = datasetSchema.parse({ ...common, assets: pngAssets }); + const terrain = datasetSchema.parse({ ...common, assets: [...pngAssets, asset("terrain.json.gz")] }); + const imported = datasetSchema.parse({ ...common, mediaFormat: "webp", assets: [asset("map.webp"), ...points.flatMap(p => faces.map(f => asset(`${p.id}/${f}.webp`)))] }); + assert.equal(usesTerrainRenderer(old), false); + assert.equal(usesTerrainRenderer(terrain), true); + assert.equal(usesTerrainRenderer(imported), false); +}); diff --git a/update_report.py b/update_report.py new file mode 100644 index 0000000..7d44dbd --- /dev/null +++ b/update_report.py @@ -0,0 +1,7 @@ +from pathlib import Path +p = Path('MineGeo community report.txt') +s = p.read_text(encoding='utf-8') +s = s.replace('Updated the Discord server description to explain the game, community maps and bilingual support.', 'Updated the Discord server description to explain the game, community maps and bilingual support. Combined support selection into the existing language answers and removed the duplicate support question. Clarified the language and notification questions in English and Russian. Created the map-showcase forum with bilingual posting guidance and an optional onboarding answer.') +s = s.replace('Add a map showcase or feedback forum when there is enough activity. Encourage one post per map with a link, screenshots and a spoiler warning; avoid creating multiple empty channels now.', 'Encourage members to use the new map-showcase forum for one post per map, with a link, screenshots and spoiler warnings. Review its activity before adding more channels.') +s = s.replace('Improve the onboarding wording: ask “Which languages do you speak?” and “Which notifications would you like?” Provide English and Russian labels consistently.', 'Continue applying English and Russian labels consistently across onboarding and the server guide.') +p.write_text(s, encoding='utf-8') diff --git a/worker/main.ts b/worker/main.ts index a897bcd..a2fb551 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -4,7 +4,12 @@ import { randomUUID, createHash } from "node:crypto"; import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises"; import { gunzipSync } from "node:zlib"; import sharp from "sharp"; -import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol"; +import { + datasetSchema, + faces, + usesTerrainRenderer, + type TerrainSnapshot, +} from "../shared/protocol"; import { processFabricImages } from "../lib/fabric-images"; import { renderSnapshot } from "../renderer/render.mjs"; import type { Database } from "../shared/database"; @@ -151,13 +156,15 @@ async function processJob(job: { .single(), ); const manifest = datasetSchema.parse(input.manifest); + const fabricTerrain = + manifest.source === "fabric" && usesTerrainRenderer(manifest); const assets = manifest.assets.filter( (a) => !mapOnly || a.path === (manifest.mediaFormat ? "map.webp" - : manifest.source === "plugin" + : manifest.source === "plugin" || fabricTerrain ? "terrain.json.gz" : "map.png"), ); @@ -201,7 +208,7 @@ async function processJob(job: { progress: (percent) => progress(20 + percent * 0.7, "Validating imported map images"), }); - } else if (manifest.source === "plugin") { + } else if (manifest.source === "plugin" || fabricTerrain) { await progress(20, "Reading the terrain snapshot"); const compressed = await readFile(`${directory}/raw/terrain.json.gz`); const terrain = JSON.parse( @@ -250,23 +257,33 @@ async function processJob(job: { ); const report = await renderSnapshot( terrain, - mapOnly ? [] : (manifest.availableViewpoints ?? manifest.viewpoints), + mapOnly || fabricTerrain + ? [] + : (manifest.availableViewpoints ?? manifest.viewpoints), output, { size: Number(process.env.MINEGEO_FACE_SIZE || 1024), minecraftVersion: manifest.minecraftVersion, signal: abort.signal, - onProgress: ({ percent, message }) => progress(percent, message), + onProgress: ({ percent, message }) => + progress(fabricTerrain ? 20 + percent * 0.5 : percent, message), }, ); console.log(`Rendered ${job.dataset_id}: ${JSON.stringify(report)}`); - } else { - await progress(25, "Compressing the captured map"); - await sharp(await readFile(`${directory}/raw/map.png`), { - limitInputPixels: 16777216, - }) - .webp({ lossless: true }) - .toFile(`${output}/map.webp`); + } + if (!manifest.mediaFormat && manifest.source === "fabric") { + await progress( + fabricTerrain ? 65 : 25, + fabricTerrain + ? "Processing captured panoramas" + : "Compressing the captured map", + ); + if (!fabricTerrain) + await sharp(await readFile(`${directory}/raw/map.png`), { + limitInputPixels: 16777216, + }) + .webp({ lossless: true }) + .toFile(`${output}/map.webp`); let processedFaces = 0; for (const point of mapOnly ? [] @@ -295,8 +312,8 @@ async function processJob(job: { .webp({ quality: 76 }) .toFile(`${output}/${point.id}/preview.webp`); await progress( - 35 + - (55 * ++processedFaces) / + (fabricTerrain ? 65 : 35) + + ((fabricTerrain ? 25 : 55) * ++processedFaces) / (manifest.viewpoints.length * faces.length), `Compressing panorama faces ${processedFaces}/${manifest.viewpoints.length * faces.length}`, );