From aec0641f87adb764c9732f3b22e1d4b66bf43bde Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Sun, 4 Oct 2026 23:12:34 +0500 Subject: [PATCH] Add panorama previews and repair beta export rendering --- README.md | 2 +- app/globals.css | 45 ++++++++++ components/DraftEditor.tsx | 62 +++++++++----- components/Moderation.tsx | 37 ++++++++- components/Panorama.tsx | 17 ++-- components/PanoramaPreview.tsx | 122 +++++++++++++++++++++++++++ components/StartGame.tsx | 1 + docs/moderation.md | 42 ++++++++++ docs/verification.md | 19 +++++ lib/api.ts | 93 +++++++++++++++++++-- package.json | 1 + renderer/block-colors.mjs | 35 ++++++++ renderer/scene.js | 35 +++++++- renderer/terrain-mesh.js | 58 ++++++++++--- scripts/assets.mjs | 6 +- scripts/repair-panoramas.mjs | 147 +++++++++++++++++++++++++++++++++ shared/draft-selection.ts | 16 ++++ shared/protocol.ts | 23 +++++- tests/beta-regressions.test.ts | 91 ++++++++++++++++++++ tests/renderer-assets.test.ts | 3 + worker/main.ts | 7 +- 21 files changed, 802 insertions(+), 60 deletions(-) create mode 100644 components/PanoramaPreview.tsx create mode 100644 docs/moderation.md create mode 100644 renderer/block-colors.mjs create mode 100644 scripts/repair-panoramas.mjs create mode 100644 shared/draft-selection.ts create mode 100644 tests/beta-regressions.test.ts diff --git a/README.md b/README.md index e3f1eb5..9050f61 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ npm start Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- ` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render). -`GET /api/v1/health` checks database connectivity. An empty catalog means no map has passed moderator review yet. Moderator access is controlled by `profiles.role` in Supabase; the project owner can set their profile's role to `moderator` using the database dashboard, then use `/moderation` on the website. +`GET /api/v1/health` checks database connectivity. An empty catalog means no map has passed moderator review yet. Moderator access is controlled by `profiles.role` in Supabase; follow [moderator setup and the first beta map](docs/moderation.md). Contributors and moderators can inspect all six panorama faces before submission or approval, and saved draft selections retain excluded locations. ## Install the capture tools diff --git a/app/globals.css b/app/globals.css index 1330597..b65de3b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1455,3 +1455,48 @@ pre { font-size: 42px; } } +/* Contributor and moderator previews use the same projection as the game. */ +.panorama-preview { + width: min(1100px, 96vw); + max-width: 96vw; + padding: 16px; + background: #0b1119; + color: #edf2ef; + border: 1px solid #58684b; +} +.panorama-preview::backdrop { + background: #000b; +} +.preview-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.preview-toolbar select { + background: #141e27; + color: inherit; + padding: 8px; +} +.preview-viewport { + position: relative; + height: min(65vh, 650px); + overflow: hidden; +} +.preview-status { + position: absolute; + inset: 0; + display: grid; + place-content: center; + background: #0b1119e8; + z-index: 5; + padding: 20px; +} +.preview-tile { + min-width: 0; +} +.preview-tile > label { + display: block; + position: relative; +} diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx index 79e5da0..6507b17 100644 --- a/components/DraftEditor.tsx +++ b/components/DraftEditor.tsx @@ -5,6 +5,7 @@ import { api } from "./StartGame"; import type { DatasetManifest } from "@/shared/protocol"; import type { ProcessingJob } from "@/shared/processing-progress"; import { ProcessingProgress } from "./ProcessingProgress"; +import { PanoramaPreview } from "./PanoramaPreview"; type Draft = { id: string; name: string; @@ -25,6 +26,7 @@ export function DraftEditor({ id }: { id: string }) { [error, setError] = useState(""), [message, setMessage] = useState(""), [busy, setBusy] = useState(false); + const [preview, setPreview] = useState(null); useEffect(() => { let disposed = false; let timer: ReturnType; @@ -55,6 +57,7 @@ export function DraftEditor({ id }: { id: string }) { async function save(submit = false) { setBusy(true); setError(""); + setMessage(""); try { await api( "datasets/" + id, @@ -147,32 +150,47 @@ export function DraftEditor({ id }: { id: string }) {

{draft.previews.map((p) => ( - +
))} + {preview && ( + setPreview(null)} + /> + )} {editable && (
{m.state === "review" ? ( <>
+ ))} )} + {preview && ( + setPreview(null)} + /> + )} ); } diff --git a/components/Panorama.tsx b/components/Panorama.tsx index b0e25b5..2c43505 100644 --- a/components/Panorama.tsx +++ b/components/Panorama.tsx @@ -82,6 +82,12 @@ export function Panorama({ ); }; let drag: { x: number; y: number } | null = null; + if (urls.length !== 6) { + callbacks.current.onError( + "This panorama is missing one or more of its six faces.", + ); + return; + } preloadFaces(urls) .then((textures) => { if (disposed) return; @@ -134,11 +140,12 @@ export function Panorama({ element.addEventListener("wheel", wheel, { passive: false }); callbacks.current.onReady(); }) - .catch((e) => + .catch((e) => { + if (disposed) return; callbacks.current.onError( "Panorama could not load. " + (e instanceof Error ? e.message : ""), - ), - ); + ); + }); return () => { disposed = true; cancelAnimationFrame(frame); @@ -170,13 +177,13 @@ export function Panorama({ <> diff --git a/components/PanoramaPreview.tsx b/components/PanoramaPreview.tsx new file mode 100644 index 0000000..80ea9ec --- /dev/null +++ b/components/PanoramaPreview.tsx @@ -0,0 +1,122 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { Panorama } from "./Panorama"; +import { api } from "./StartGame"; + +export function PanoramaPreview({ + endpoint, + initialId, + onClose, +}: { + endpoint: string; + initialId?: string; + onClose: () => void; +}) { + const dialog = useRef(null); + const [locations, setLocations] = useState([]), + [selected, setSelected] = useState(initialId ?? ""), + [urls, setUrls] = useState(null), + [ready, setReady] = useState(false), + [error, setError] = useState(""); + useEffect(() => { + dialog.current?.showModal(); + let disposed = false; + api<{ locations: string[] }>(endpoint) + .then((d) => { + if (disposed) return; + setLocations(d.locations); + setSelected((s) => + d.locations.includes(s) ? s : (d.locations[0] ?? ""), + ); + }) + .catch((e) => { + if (!disposed) setError(e.message); + }); + return () => { + disposed = true; + }; + }, [endpoint]); + useEffect(() => { + if (!selected) return; + let disposed = false; + setUrls(null); + setReady(false); + setError(""); + api<{ faces: string[] }>(`${endpoint}/${selected}`) + .then((d) => { + if (!disposed) setUrls(d.faces); + }) + .catch((e) => { + if (!disposed) setError(e.message); + }); + return () => { + disposed = true; + }; + }, [endpoint, selected]); + const index = locations.indexOf(selected); + return ( + +
+ + + + +
+
+ {urls && ( + setReady(true)} + onError={setError} + /> + )} + {error ? ( +

+ {error} +

+ ) : ( + !ready && ( +

+ Loading panorama… +

+ ) + )} +
+

+ Drag to look around · Scroll to zoom · Check for blocked views and + missing terrain. +

+
+ ); +} diff --git a/components/StartGame.tsx b/components/StartGame.tsx index 86b70bd..7a5e506 100644 --- a/components/StartGame.tsx +++ b/components/StartGame.tsx @@ -9,6 +9,7 @@ export async function api>( method = body ? "POST" : "GET", ): Promise { const res = await fetch("/api/v1/" + path, { + signal: AbortSignal.timeout(30000), method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, diff --git a/docs/moderation.md b/docs/moderation.md new file mode 100644 index 0000000..b699c9b --- /dev/null +++ b/docs/moderation.md @@ -0,0 +1,42 @@ +# Moderator access + +MineGeo checks `public.profiles.role` on the server for every moderation request. A contributor cannot change their own role through the website or Supabase's public API. No Discord role, JWT metadata, or additional plugin is required. + +1. Have the moderator sign in to MineGeo and open their dashboard once. +2. In the **MineGeo** Supabase project, open **Authentication → Users** and copy that person's user UUID. +3. Open **Table Editor → public.profiles**, find the matching `id`, and set `role` to `moderator`. Keep the existing ID and display name. +4. The moderator can open `/moderation`, inspect each location in 360°, approve a submitted map, return it with feedback, unpublish a map, and resolve community reports. The role takes effect on the next request. + +Alternatively, the project owner can run this in Supabase's SQL Editor, replacing the email with the intended account: + +```sql +insert into public.profiles (id, role) +select id, 'moderator' +from auth.users +where lower(email) = lower('moderator@example.com') +on conflict (id) do update set role = excluded.role +returning id, role; +``` + +The query must return the intended user's ID. An empty result means the person has not signed in with that email yet. To remove moderator access, change that profile's role back to `contributor`. + +Treat the website role separately from Supabase organization membership: moderators do not need access to your Supabase project or its secret key. The existing test moderator account is a verification fixture; it does not grant access to your personal account. + +# First beta map + +After deploying the updated website and worker: + +1. Open the private draft in `/dashboard`. Use **Look around** to inspect its complete panorama, including terrain near the edge of the export. +2. Keep at least five usable locations. Saved exclusions can now be restored after a reload. Locations removed by older versions cannot be recovered unless the original capture manifest is available. +3. Save and submit the map for review. +4. A moderator opens `/moderation`, checks **Inspect 360° locations**, and approves the map. It then appears in **Play & maps** and visitors can play without signing in. + +The rendering fix must run on existing images as well as new uploads. This session repaired the private draft `00ff0076-cae1-4a0d-ab81-9014833309f4` in Supabase while preserving its ten selected locations, metadata and original terrain upload. Refresh its preview to see the corrected textures. + +For another completed private plugin draft with the same rendering issue: + +```sh +npm run repair:panoramas -- +``` + +This command renders and verifies the original terrain first, then replaces the draft's private media. It refuses published revisions, maps under review, and active/failed processing jobs. For failed processing, use the existing `npm run reprocess -- ` recovery workflow instead. Server-generated views retain simplified lighting and biome color approximations; entities, custom packs and dynamic sign text remain outside the capture scope. diff --git a/docs/verification.md b/docs/verification.md index 5ee9115..4b8892d 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,5 +1,24 @@ # Verification of the export/upload fixes +## First beta texture and usability fixes (October 4, 2026) + +The renderer previously treated `waterlogged=false` on leaf blocks as water and tinted trees blue. Tint selection now uses the block identifier and exported biome. Animated vanilla sprite sheets use a single frame, grass overlays avoid depth flickering, partial block models use their derived face UVs, and element rotations also rotate lighting normals. + +Verification completed against the configured MineGeo project: + +- All 31 unit tests and TypeScript checking passed, including blue-leaf tint, sprite frame selection, restoring excluded locations after reload, and invalid selections. +- The Next.js 16.3.8 production build passed. +- Both the small renderer fixture and cached real Paper export passed, with no unsupported models. +- The user's 2,651,888-block private export rendered 8,838,392 faces and all ten selected panoramas with no unsupported models; its corrected map/previews were uploaded to Supabase. Windows/AMD geometry construction took about 8.8 seconds; the whole render took about 23.7 seconds, excluding download/upload. These measurements do not establish macOS performance. +- A temporary fixture verified private-draft denial, draft selection persistence/restoration, interactive 360 previews, moderator review and approval, report resolution, and a guest game through all five rounds to results with real Supabase timers/scores. A different browser could not read the guest session. No uncaught browser errors were observed. The fixture was unpublished, media removed, and database rows cleaned up afterward. +- After the user's browser preference was updated, remaining UI checks used Codex's built-in Browser. The earlier standalone test browser and its terminal window were closed. + +Local screenshots and the regression report are retained in `.cache/beta-verification`. The repaired export's report and images are retained in `.cache/panorama-repair/00ff0076-cae1-4a0d-ab81-9014833309f4`. The user's map remains a private draft pending their moderator workflow. The website UI changes need deployment; no new database migration is required. + +The Supabase security advisor reported no application schema warnings; tables without client policies are intentionally server-only. Its separate leaked-password-protection warning concerns the project's authentication setting; see [Supabase's password protection guidance](https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection). The site uses magic-link sign-in; the beta test used an existing password-based fixture account. + +--- + The following checks passed on Windows with Java 21 and Minecraft/Paper 1.21.11: - A temporary Paper server with no connected player exported 16 chunks / 10,112 retained blocks and five valid surface viewpoints. diff --git a/lib/api.ts b/lib/api.ts index 775e5bb..5626f5d 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -6,6 +6,7 @@ import { admin, required, user } from "./supabase"; import { datasetSchema, faces, difficulties } from "../shared/protocol"; import { mediaUrl } from "./catalog"; import { requestOrigin, verifyOrigin } from "./request-origin"; +import { selectDraftLocations } from "../shared/draft-selection"; const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const idSchema = z.string().uuid(); @@ -355,6 +356,21 @@ export async function handle(request: NextRequest, path: string[]) { }); } const d = await ownedDataset(request, path[1]); + if (method === "GET" && path[2] === "panoramas") { + const input = required( + await client + .from("dataset_inputs") + .select("manifest") + .eq("dataset_id", d.id) + .single(), + ); + const manifest = datasetSchema.parse(input.manifest); + return panoramaResponse( + d.id, + manifest.availableViewpoints ?? manifest.viewpoints, + path[3], + ); + } if (method === "GET") { const input = required( await client @@ -376,12 +392,14 @@ export async function handle(request: NextRequest, path: string[]) { manifest, map: await mediaUrl(d.map_path), previews: await Promise.all( - manifest.viewpoints.map(async (p: { id: string }) => ({ - id: p.id, - image: d.map_path - ? await mediaUrl(`${d.id}/${p.id}/preview.webp`) - : null, - })), + (manifest.availableViewpoints ?? manifest.viewpoints).map( + async (p: { id: string }) => ({ + id: p.id, + image: d.map_path + ? await mediaUrl(`${d.id}/${p.id}/preview.webp`) + : null, + }), + ), ), }); } @@ -408,9 +426,7 @@ export async function handle(request: NextRequest, path: string[]) { description: body.description, }; if (body.selectedIds) - manifest.viewpoints = manifest.viewpoints.filter( - (p: { id: string }) => body.selectedIds!.includes(p.id), - ); + manifest = selectDraftLocations(manifest, body.selectedIds); manifest = datasetSchema.parse(manifest); required( await client @@ -427,8 +443,10 @@ export async function handle(request: NextRequest, path: string[]) { name: body.name, description: body.description, panorama_count: manifest.viewpoints.length, + cover_path: `${d.id}/${manifest.viewpoints[0].id}/preview.webp`, state: "draft", rejection_reason: null, + updated_at: new Date().toISOString(), }) .eq("id", d.id) .select("id") @@ -596,6 +614,29 @@ export async function handle(request: NextRequest, path: string[]) { } if (path[0] === "moderation") { const reviewer = await moderator(request); + if (method === "GET" && path[2] === "panoramas") { + const d = required( + await client + .from("datasets") + .select("id,state") + .eq("id", uuid(path[1])) + .single(), + ); + if (!["review", "published"].includes(d.state)) + throw new Error("Map is not awaiting review or published"); + const input = required( + await client + .from("dataset_inputs") + .select("manifest") + .eq("dataset_id", d.id) + .single(), + ); + return panoramaResponse( + d.id, + datasetSchema.parse(input.manifest).viewpoints, + path[3], + ); + } if (method === "GET") { const maps = required( await client @@ -657,6 +698,21 @@ export async function handle(request: NextRequest, path: string[]) { ); return NextResponse.json({ ok: true }); } + if (path[0] === "reports" && method === "PATCH") { + await moderator(request); + const b = z + .object({ state: z.literal("resolved") }) + .parse(await jsonBody(request)); + required( + await client + .from("reports") + .update({ state: b.state }) + .eq("id", uuid(path[1])) + .select("id") + .single(), + ); + return NextResponse.json({ ok: true }); + } return NextResponse.json({ error: "Not found" }, { status: 404 }); } catch (error) { const message = @@ -675,3 +731,22 @@ export async function handle(request: NextRequest, path: string[]) { return NextResponse.json({ error: message }, { status }); } } + +async function panoramaResponse( + datasetId: string, + locations: { id: string }[], + pointId?: string, +) { + if (!pointId) + return NextResponse.json({ locations: locations.map((p) => p.id) }); + if (!locations.some((p) => p.id === pointId)) + throw new Error("Location not found"); + return NextResponse.json( + { + faces: await Promise.all( + faces.map((f) => mediaUrl(`${datasetId}/${pointId}/${f}.webp`)), + ), + }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/package.json b/package.json index f27de73..3bc19a8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "node scripts/supervise.mjs production", "setup": "node scripts/setup.mjs", "reprocess": "node scripts/reprocess.mjs", + "repair:panoramas": "node scripts/repair-panoramas.mjs", "worker": "tsx worker/main.ts", "test": "tsx --test tests/*.test.ts", "test:renderer": "node scripts/test-renderer.mjs", diff --git a/renderer/block-colors.mjs b/renderer/block-colors.mjs new file mode 100644 index 0000000..8faf9e1 --- /dev/null +++ b/renderer/block-colors.mjs @@ -0,0 +1,35 @@ +// Properties such as waterlogged=false must never determine the block's tint. +export function blockTint(state, biome = "minecraft:plains") { + const name = state.split("[")[0].replace(/^minecraft:/, ""); + if (name === "water") return biome.includes("swamp") ? "#617b64" : "#3f76e4"; + if (name === "spruce_leaves") return "#619961"; + if (name === "birch_leaves") return "#80a755"; + if (name === "redstone_wire") { + const power = Number(state.match(/power=(\d+)/)?.[1] || 0); + return power ? "#ff3300" : "#4c0000"; + } + const leaves = name.endsWith("_leaves") || name === "vine"; + if (biome.includes("swamp")) return "#6a7039"; + if (/savanna|badlands|desert/.test(biome)) + return leaves ? "#aea42a" : "#bfb755"; + if (/snow|frozen|taiga/.test(biome)) return leaves ? "#60a17b" : "#80b497"; + if (/jungle/.test(biome)) return leaves ? "#30bb0b" : "#59c93c"; + return leaves ? "#77ab2f" : "#91bd59"; +} + +export function firstTextureFrame(width, height, animation) { + if (!animation) return { x: 0, y: 0, width, height }; + const w = + animation.width || (animation.height ? width : Math.min(width, height)); + const h = + animation.height || (animation.width ? height : Math.min(width, height)); + const first = animation.frames?.[0] ?? 0; + const index = typeof first === "number" ? first : first.index; + const columns = Math.floor(width / w); + return { + x: (index % columns) * w, + y: Math.floor(index / columns) * h, + width: w, + height: h, + }; +} diff --git a/renderer/scene.js b/renderer/scene.js index 0bfd0ac..2a82d55 100644 --- a/renderer/scene.js +++ b/renderer/scene.js @@ -1,5 +1,6 @@ import * as THREE from "/three.js"; import { buildTerrain } from "./terrain-mesh.js"; +import { firstTextureFrame } from "./block-colors.mjs"; // This renderer consumes snapshots, never connects to a Minecraft server. const renderer = new THREE.WebGLRenderer({ antialias: true, @@ -70,7 +71,39 @@ async function texture(ref) { ref, loader .loadAsync(resourcePath(ref, "textures") + ".png") - .then((t) => { + .then(async (t) => { + // Vanilla animated textures are sprite sheets, not stretched faces. + const metadata = + t.image.width !== t.image.height + ? await fetch(resourcePath(ref, "textures") + ".png.mcmeta").then( + (r) => (r.ok ? r.json() : null), + ) + : null; + if (metadata?.animation) { + const frame = firstTextureFrame( + t.image.width, + t.image.height, + metadata.animation, + ); + const canvas = document.createElement("canvas"); + canvas.width = frame.width; + canvas.height = frame.height; + canvas + .getContext("2d") + .drawImage( + t.image, + frame.x, + frame.y, + frame.width, + frame.height, + 0, + 0, + frame.width, + frame.height, + ); + t.image = canvas; + t.needsUpdate = true; + } t.magFilter = THREE.NearestFilter; t.minFilter = THREE.NearestFilter; t.colorSpace = THREE.SRGBColorSpace; diff --git a/renderer/terrain-mesh.js b/renderer/terrain-mesh.js index 2830f52..43d7148 100644 --- a/renderer/terrain-mesh.js +++ b/renderer/terrain-mesh.js @@ -1,5 +1,6 @@ import * as THREE from "/three.js"; import { OpaqueIndex, QuadBuffer } from "./mesh-buffers.mjs"; +import { blockTint } from "./block-colors.mjs"; const nonOpaque = /air|water|lava|leaves|glass|grass(?!_block)|fern|flower|sapling|torch|fence|wall|slab|stairs|door|pane|rail|vine|carpet/; @@ -16,6 +17,7 @@ async function compileState( texture, materials, unsupported, + biome, ) { const faces = []; for (const { m, v = {} } of variants) { @@ -40,13 +42,7 @@ async function compileState( continue; } const tint = - f.tintindex !== undefined - ? state.includes("water") - ? "#5b9eb5" - : state.includes("leaves") - ? "#7ba54c" - : "#90b650" - : "#ffffff"; + f.tintindex !== undefined ? blockTint(state, biome) : "#ffffff"; const key = ref + "|" + tint; let material = materials.get(key); if (!material) { @@ -60,14 +56,30 @@ async function compileState( alphaTest: 0.3, transparent: water, opacity: water ? 0.8 : 1, + // Grass side overlays share a plane with the dirt face. + polygonOffset: ref?.includes("grass_block_side_overlay"), + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, }), }; materials.set(key, material); } const normal = new THREE.Vector3(...fd.normal).applyMatrix4(rotation); + if (e.rotation) { + const axis = new THREE.Vector3( + e.rotation.axis === "x" ? 1 : 0, + e.rotation.axis === "y" ? 1 : 0, + e.rotation.axis === "z" ? 1 : 0, + ); + normal.copy( + new THREE.Vector3(...fd.normal) + .applyAxisAngle(axis, (e.rotation.angle * Math.PI) / 180) + .applyMatrix4(rotation), + ); + } const positions = new Float64Array(12), uvs = new Float32Array(8); - const uv = f.uv || [0, 0, 16, 16]; + const uv = f.uv || defaultUV(dir, e.from, e.to); const coords = [ [uv[0] / 16, 1 - uv[3] / 16], [uv[2] / 16, 1 - uv[3] / 16], @@ -143,6 +155,17 @@ function fluidModel(state, faceData) { }; } +function defaultUV(dir, from, to) { + const [x, y, z] = from, + [X, Y, Z] = to; + if (dir === "up") return [x, z, X, Z]; + if (dir === "down") return [x, 16 - Z, X, 16 - z]; + if (dir === "north") return [16 - X, 16 - Y, 16 - x, 16 - y]; + if (dir === "south") return [x, 16 - Y, X, 16 - y]; + if (dir === "west") return [z, 16 - Y, Z, 16 - y]; + return [16 - Z, 16 - Y, 16 - z, 16 - y]; +} + export async function buildTerrain( snapshot, scene, @@ -176,8 +199,14 @@ export async function buildTerrain( const b = snapshot.blocks[i]; if (b[1] < minY) minY = b[1]; if (b[1] > maxY) maxY = b[1]; - if (!states.has(b[3])) - states.set(b[3], { opaque: !nonOpaque.test(b[3]), faces: null }); + const key = b[3] + "|" + (b[4] || "minecraft:plains"); + if (!states.has(key)) + states.set(key, { + state: b[3], + biome: b[4], + opaque: !nonOpaque.test(b[3].split("[")[0]), + faces: null, + }); if (i % 16384 === 0) await progress( 25 + (2 * i) / snapshot.blocks.length, @@ -187,7 +216,8 @@ export async function buildTerrain( const occupancy = new OpaqueIndex(snapshot.bounds, minY, maxY); for (let i = 0; i < snapshot.blocks.length; i++) { const b = snapshot.blocks[i]; - if (states.get(b[3]).opaque) occupancy.add(b[0], b[1], b[2]); + if (states.get(b[3] + "|" + (b[4] || "minecraft:plains")).opaque) + occupancy.add(b[0], b[1], b[2]); if (i % 16384 === 0) await progress( 27 + (3 * i) / snapshot.blocks.length, @@ -203,7 +233,8 @@ export async function buildTerrain( for (let i = 0; i < entries.length; i += 8) { const end = Math.min(i + 8, entries.length); await Promise.all( - entries.slice(i, end).map(async ([state, entry]) => { + entries.slice(i, end).map(async ([, entry]) => { + const state = entry.state; const variants = fluid.test(state) ? [fluidModel(state, faceData)] : await stateModels(state); @@ -215,6 +246,7 @@ export async function buildTerrain( texture, materials, unsupported, + entry.biome, ); completedModels++; }), @@ -261,7 +293,7 @@ export async function buildTerrain( x = b[0], y = b[1], z = b[2]; - const entry = states.get(b[3]); + const entry = states.get(b[3] + "|" + (b[4] || "minecraft:plains")); const region = Math.floor(x / 64) - originX + diff --git a/scripts/assets.mjs b/scripts/assets.mjs index 6c8e98d..f83d9ee 100644 --- a/scripts/assets.mjs +++ b/scripts/assets.mjs @@ -18,7 +18,7 @@ async function assetsReady() { try { const marker = JSON.parse(await readFile(completionMarker, "utf8")); if ( - marker.format !== "minegeo-assets/v1" || + marker.format !== "minegeo-assets/v2" || marker.minecraftVersion !== "1.21.11" || !marker.files ) @@ -89,7 +89,7 @@ export async function prepareAssets() { filter: (f) => /^assets\/minecraft\/(models|blockstates|textures\/block)\//.test( f.name, - ) && /\.(json|png)$/.test(f.name), + ) && /\.(json|png|mcmeta)$/.test(f.name), }); for (const [name, bytes] of Object.entries(entries)) { const path = `${assetsRoot}/${name}`; @@ -100,7 +100,7 @@ export async function prepareAssets() { await writeFile( completionMarker, JSON.stringify({ - format: "minegeo-assets/v1", + format: "minegeo-assets/v2", minecraftVersion: "1.21.11", clientSha1: version.downloads.client.sha1, files: Object.keys(entries).length, diff --git a/scripts/repair-panoramas.mjs b/scripts/repair-panoramas.mjs new file mode 100644 index 0000000..c9add48 --- /dev/null +++ b/scripts/repair-panoramas.mjs @@ -0,0 +1,147 @@ +import { createClient } from "@supabase/supabase-js"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { gunzipSync } from "node:zlib"; +import { renderSnapshot } from "../renderer/render.mjs"; + +if (existsSync(".env.local")) process.loadEnvFile(".env.local"); +const id = process.argv[2]; +if ( + !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + id || "", + ) +) + throw new Error( + "Usage: npm run repair:panoramas -- ", + ); +const client = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.SUPABASE_SECRET_KEY, + { auth: { persistSession: false, autoRefreshToken: false } }, +); +function check(r) { + if (r.error) throw new Error(r.error.message); + return r.data; +} +const dataset = check( + await client.from("datasets").select("*").eq("id", id).single(), +); +if ( + dataset.source !== "plugin" || + dataset.revision_id || + !["draft", "rejected"].includes(dataset.state) +) + throw new Error( + "Only a private, unpublished plugin draft can be repaired. Withdraw review or recover failed processing first.", + ); +const job = check( + await client + .from("upload_jobs") + .select("state") + .eq("dataset_id", id) + .single(), +); +if (job.state !== "complete") + throw new Error("Wait for processing to finish first."); +const input = check( + await client + .from("dataset_inputs") + .select("manifest,validated") + .eq("dataset_id", id) + .single(), +); +if (!input.validated) throw new Error("The export must be validated first."); +const asset = input.manifest.assets.find((a) => a.path === "terrain.json.gz"); +if (!asset) throw new Error("Missing original terrain export"); +const raw = check( + await client.storage + .from("minegeo-raw") + .download(`${dataset.owner_id}/${id}/${asset.path}`), +); +const bytes = Buffer.from(await raw.arrayBuffer()); +if ( + bytes.length !== asset.bytes || + createHash("sha256").update(bytes).digest("hex") !== asset.sha256 +) + throw new Error("Original terrain checksum mismatch"); +const output = `.cache/panorama-repair/${id}`; +await mkdir(output, { recursive: true }); +const terrain = JSON.parse( + gunzipSync(bytes, { maxOutputLength: 256 * 1024 * 1024 }).toString(), +); +await renderSnapshot( + terrain, + input.manifest.availableViewpoints ?? input.manifest.viewpoints, + output, + { + size: Number(process.env.MINEGEO_FACE_SIZE || 1024), + onProgress: async ({ percent, message }) => + console.log(`${Math.floor(percent)}% ${message}`), + }, +); +// Render first, then briefly lock the draft while replacing its private images. +// Its complete job stays complete, so another worker cannot pick it up. +const locked = check( + await client + .from("datasets") + .update({ state: "processing", updated_at: new Date().toISOString() }) + .eq("id", id) + .eq("state", dataset.state) + .eq("updated_at", dataset.updated_at) + .is("revision_id", null) + .select("id"), +); +if (locked.length !== 1) + throw new Error( + "Draft changed during rendering; images were retained locally but nothing was replaced.", + ); +try { + for (const entry of (await readdir(output, { recursive: true })).filter((p) => + p.endsWith(".webp"), + )) { + check( + await client.storage + .from("minegeo-media") + .upload( + `${id}/${entry.replaceAll("\\", "/")}`, + await readFile(`${output}/${entry}`), + { upsert: true, contentType: "image/webp", cacheControl: "60" }, + ), + ); + } + check( + await client + .from("datasets") + .update({ state: dataset.state, updated_at: new Date().toISOString() }) + .eq("id", id) + .eq("state", "processing") + .is("revision_id", null) + .select("id") + .single(), + ); + await writeFile( + `${output}/repair.json`, + JSON.stringify({ + datasetId: id, + repaired: true, + at: new Date().toISOString(), + }), + ); + console.log( + `Repaired draft ${id}. Reload its dashboard preview; no Minecraft export or new upload is needed.`, + ); +} catch (error) { + await client + .from("datasets") + .update({ + state: "failed", + rejection_reason: + "Image repair interrupted. Reprocess the retained raw upload.", + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("state", "processing") + .is("revision_id", null); + throw error; +} diff --git a/shared/draft-selection.ts b/shared/draft-selection.ts new file mode 100644 index 0000000..6a7bc65 --- /dev/null +++ b/shared/draft-selection.ts @@ -0,0 +1,16 @@ +import { datasetSchema, type DatasetManifest } from "./protocol"; + +export function selectDraftLocations(manifest: DatasetManifest, ids: string[]) { + const available = manifest.availableViewpoints ?? manifest.viewpoints; + const selected = new Set(ids); + if ( + selected.size !== ids.length || + ids.some((id) => !available.some((p) => p.id === id)) + ) + throw new Error("Choose unique locations from this capture"); + return datasetSchema.parse({ + ...manifest, + availableViewpoints: available, + viewpoints: available.filter((p) => selected.has(p.id)), + }); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index a3f477d..97d92af 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -38,6 +38,14 @@ export const datasetSchema = z ) .min(5) .max(100), + // Retain the capture's locations while viewpoints holds the saved selection. + availableViewpoints: z + .array( + pointSchema.extend({ id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }), + ) + .min(5) + .max(100) + .optional(), assets: z .array( z.object({ @@ -62,7 +70,7 @@ export const datasetSchema = z ? ["terrain.json.gz"] : [ "map.png", - ...d.viewpoints.flatMap((point) => + ...(d.availableViewpoints ?? d.viewpoints).flatMap((point) => faces.map((face) => `${point.id}/${face}.png`), ), ]; @@ -75,9 +83,20 @@ export const datasetSchema = z } if (new Set(d.viewpoints.map((p) => p.id)).size !== d.viewpoints.length) ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" }); + if (d.availableViewpoints) { + const ids = new Set(d.availableViewpoints.map((p) => p.id)); + if ( + ids.size !== d.availableViewpoints.length || + d.viewpoints.some((p) => !ids.has(p.id)) + ) + ctx.addIssue({ + code: "custom", + message: "Selection must use unique captured locations", + }); + } if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length) ctx.addIssue({ code: "custom", message: "Asset paths must be unique" }); - for (const p of d.viewpoints) + for (const p of [...d.viewpoints, ...(d.availableViewpoints ?? [])]) if ( p.x < d.bounds.minX || p.x > d.bounds.maxX || diff --git a/tests/beta-regressions.test.ts b/tests/beta-regressions.test.ts new file mode 100644 index 0000000..5419898 --- /dev/null +++ b/tests/beta-regressions.test.ts @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { blockTint, firstTextureFrame } from "../renderer/block-colors.mjs"; +import { datasetSchema } from "../shared/protocol"; +import { selectDraftLocations } from "../shared/draft-selection"; + +test("waterlogged properties do not turn leaves, grass or vines blue", () => { + for (const name of ["acacia_leaves", "oak_leaves", "vine", "short_grass"]) { + assert.equal( + blockTint(`minecraft:${name}[waterlogged=false]`), + blockTint(`minecraft:${name}`), + ); + assert.notEqual( + blockTint(`minecraft:${name}[waterlogged=true]`), + blockTint("minecraft:water"), + ); + } + assert.equal( + blockTint("minecraft:birch_leaves[waterlogged=false]"), + "#80a755", + ); + assert.notEqual( + blockTint("minecraft:acacia_leaves", "minecraft:savanna"), + blockTint("minecraft:acacia_leaves", "minecraft:jungle"), + ); +}); + +test("animated textures use one frame, including explicit sprite frame order", () => { + assert.deepEqual(firstTextureFrame(16, 512, {}), { + x: 0, + y: 0, + width: 16, + height: 16, + }); + assert.deepEqual( + firstTextureFrame(32, 64, { + width: 16, + height: 16, + frames: [{ index: 3, time: 4 }], + }), + { x: 16, y: 16, width: 16, height: 16 }, + ); + assert.deepEqual(firstTextureFrame(32, 16, null), { + x: 0, + y: 0, + width: 32, + height: 16, + }); +}); + +const capture = datasetSchema.parse({ + format: "minegeo-dataset/v1", + minecraftVersion: "1.21.11", + dimension: "minecraft:overworld", + source: "plugin", + name: "Selection regression", + serverName: "Test", + north: "-z", + bounds: { minX: 0, minZ: 0, maxX: 16, maxZ: 16 }, + viewpoints: Array.from({ length: 6 }, (_, i) => ({ + id: `p${i}`, + x: i, + y: 66, + z: i, + })), + assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }], +}); +test("saving and reloading a selection retains deselected capture locations", () => { + const saved = selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "p4"]); + const reloaded = datasetSchema.parse(JSON.parse(JSON.stringify(saved))); + assert.equal(reloaded.viewpoints.length, 5); + assert.equal(reloaded.availableViewpoints?.length, 6); + const restored = selectDraftLocations(reloaded, [ + "p0", + "p1", + "p2", + "p3", + "p5", + ]); + assert.equal(restored.viewpoints[4].id, "p5"); + assert.equal(restored.availableViewpoints?.length, 6); +}); +test("unknown, duplicate or too few locations cannot enter a published selection", () => { + assert.throws(() => + selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "missing"]), + ); + assert.throws(() => + selectDraftLocations(capture, ["p0", "p1", "p2", "p3", "p3"]), + ); + assert.throws(() => selectDraftLocations(capture, ["p0", "p1", "p2", "p3"])); +}); diff --git a/tests/renderer-assets.test.ts b/tests/renderer-assets.test.ts index 5501b61..5ca93cb 100644 --- a/tests/renderer-assets.test.ts +++ b/tests/renderer-assets.test.ts @@ -23,6 +23,9 @@ const files = { "textures/block/dirt.png": png, "textures/block/stone.png": png, "textures/block/water_still.png": png, + "textures/block/water_still.png.mcmeta": Buffer.from( + '{"animation":{"frametime":2}}', + ), }; const jar = zipSync( Object.fromEntries( diff --git a/worker/main.ts b/worker/main.ts index dc46730..eea93ff 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -179,7 +179,7 @@ async function processJob(job: { ); const report = await renderSnapshot( terrain, - manifest.viewpoints, + manifest.availableViewpoints ?? manifest.viewpoints, output, { size: Number(process.env.MINEGEO_FACE_SIZE || 1024), @@ -196,7 +196,7 @@ async function processJob(job: { .webp({ quality: 90 }) .toFile(`${output}/map.webp`); let processedFaces = 0; - for (const point of manifest.viewpoints) { + for (const point of manifest.availableViewpoints ?? manifest.viewpoints) { await mkdir(`${output}/${point.id}`, { recursive: true }); for (const face of faces) { const bytes = await readFile( @@ -257,7 +257,8 @@ async function processJob(job: { { contentType: "image/webp", upsert: true, - cacheControl: "31536000", + // Drafts can be repaired before publication. + cacheControl: "60", }, ), );