Fail fast on missing terrain assets and recover empty renders

This commit is contained in:
2026-10-04 22:32:11 +05:00
parent 79d0f53a79
commit 763cf8713c
15 changed files with 842 additions and 21 deletions

View File

@@ -22,6 +22,8 @@ npm start
`npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker. `npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker.
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 -- <dataset UUID>` 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; the project owner can set their profile's role to `moderator` using the database dashboard, then use `/moderation` on the website.
## Install the capture tools ## Install the capture tools
@@ -43,6 +45,7 @@ The build copies both JARs to their Git-visible download folders. Include those
```sh ```sh
npm test npm test
npm run test:renderer
npm run typecheck -- --incremental false npm run typecheck -- --incremental false
npm run build npm run build
node --import tsx scripts/test-plugin.ts node --import tsx scripts/test-plugin.ts
@@ -50,6 +53,8 @@ node --import tsx scripts/test-plugin.ts
The plugin regression uses the Paper JAR already downloaded into `.cache/paper-proof/paper.jar`, starts a temporary loopback-only Paper server on port 25580, and stops it automatically. It verifies export, manifest validation, missing-export instructions, interrupted TUS upload/resume, idempotent retries, and cancellation. Its upload endpoint is a local test server; it does not publish a real community map or launch a Minecraft client. The plugin regression uses the Paper JAR already downloaded into `.cache/paper-proof/paper.jar`, starts a temporary loopback-only Paper server on port 25580, and stops it automatically. It verifies export, manifest validation, missing-export instructions, interrupted TUS upload/resume, idempotent retries, and cancellation. Its upload endpoint is a local test server; it does not publish a real community map or launch a Minecraft client.
The renderer regression exercises the product's headless terrain worker with grass, water, trees and a building, verifies nonempty map/cubemap output, and checks that a missing model fails. It starts no Minecraft server and uses no website UI automation. Output is written to `.cache/renderer-regression`. To use an existing Paper export instead, run `npm run test:renderer -- <export-folder>` after setup.
## Rendering scope ## Rendering scope
Plugin exports include generated Overworld surface terrain (up to 24 blocks below each column's top), vanilla block states, and biome identifiers. Buried opaque blocks are omitted. Each export is limited to 5 million retained blocks, 240 MiB of raw terrain JSON, and 50 MiB compressed. Start with a 128 × 128 area, check the preview, then increase coverage. Lighting is simplified; custom packs, entities, and dynamic block-entity visuals are not captured by the server exporter. Plugin exports include generated Overworld surface terrain (up to 24 blocks below each column's top), vanilla block states, and biome identifiers. Buried opaque blocks are omitted. Each export is limited to 5 million retained blocks, 240 MiB of raw terrain JSON, and 50 MiB compressed. Start with a 128 × 128 area, check the preview, then increase coverage. Lighting is simplified; custom packs, entities, and dynamic block-entity visuals are not captured by the server exporter.

View File

@@ -68,6 +68,29 @@ If the worker stops renewing its lease, the page warns that it has stopped repor
- `minegeo status` reports the active stage and the last completed export folder. - `minegeo status` reports the active stage and the last completed export folder.
- `minegeo help` lists commands. - `minegeo help` lists commands.
If processing fails on the website, read the draft's error. Fix the worker/setup or reduce the export area, then create and upload a new export. Keep the old folders until you no longer need them. If processing fails on the website, read the draft's error. A renderer/setup failure can be retried using the already uploaded export; you do not need to export or upload it again.
### Recover an empty render
A report with `meshes: 0`, `emittedFaces: 0`, and ordinary blocks such as stone and dirt in `unsupported` means Minecraft assets did not reach the renderer. Those renders previously completed incorrectly with sky-only images. Assets are local to each host: the ignored `.cache` folder does not transfer when pulling Git changes.
The worker now prepares missing/incomplete vanilla assets before rendering. Setup prepares them before installing Chromium, so a browser installation failure cannot skip the asset download. A completion marker is written after extraction; core models/textures are checked on subsequent runs. Missing models, zero geometry, lost graphics contexts and sky-only downward/map images fail processing instead of producing a playable draft.
To regenerate an existing unpublished draft after pulling this fix:
1. Stop MineGeo with Ctrl+C so its worker is stopped.
2. Run this from the repository root, replacing the UUID with the draft's ID:
```sh
npm run reprocess -- 00ff0076-cae1-4a0d-ab81-9014833309f4
```
3. Restart with your usual `npm run dev` or `npm start` command. On the Mac test server:
```sh
PORT=3008 MINEGEO_HOST=0.0.0.0 MINEGEO_ALLOW_ANY_ORIGIN=true npm run dev
```
The maintenance command prepares plugin rendering assets, keeps the original raw export, clears the previous preview metadata and queues a fresh render. It refuses published revisions and active jobs. Inspect the regenerated private draft and submit it for review again. If the export itself is invalid, create and upload a corrected export instead.
Expected limits: Minecraft 1.21.11 / Java 21, Overworld only, five or more safe viewpoints, at most 2048 blocks per selected side and the worker's terrain/file-size limits. A large selection may need to be split into smaller maps. Expected limits: Minecraft 1.21.11 / Java 21, Overworld only, five or more safe viewpoints, at most 2048 blocks per selected side and the worker's terrain/file-size limits. A large selection may need to be split into smaller maps.

View File

@@ -26,3 +26,17 @@ This pass did not launch a Minecraft client, use Playwright, or use the Computer
The subsequent renderer changes replace per-block model transformation and texture loading with cached state templates, numeric section bitsets, bounded typed geometry buffers and spatial meshes. WebP output avoids an intermediate PNG encode, and processed-image uploads run in a four-request pool. GPU selection is automatic with software fallback on initialization failure. The subsequent renderer changes replace per-block model transformation and texture loading with cached state templates, numeric section bitsets, bounded typed geometry buffers and spatial meshes. WebP output avoids an intermediate PNG encode, and processed-image uploads run in a four-request pool. GPU selection is automatic with software fallback on initialization failure.
No tests, browsers or benchmarks were run for this change at the user's request. Its speedup and image fidelity on the 2,651,888-block Mac export are unverified. The new per-stage timings, blocks-per-second logs and geometry counts provide measurements for the next real run. No tests, browsers or benchmarks were run for this change at the user's request. Its speedup and image fidelity on the 2,651,888-block Mac export are unverified. The new per-stage timings, blocks-per-second logs and geometry counts provide measurements for the next real run.
## Empty-render recovery
The subsequent Mac report showed 2,651,888 input blocks but zero meshes, emitted faces and geometry bytes. Ordinary vanilla block models and fluid textures were all unavailable. The worker previously skipped those failures and completed sky-only previews. The fix prepares missing/incomplete assets before rendering, checks extraction completion, and rejects zero geometry or sky-only downward/map output.
After the user authorized tests again, the following checks passed on Windows using the pinned Playwright 1.56.1 / Chromium 1194 product renderer:
- All 27 unit tests, including missing/interrupted asset caches, missing textures, corrupted client checksums, negative-coordinate occupancy, geometry-buffer growth, sky-only WebP rejection, and reprocessing safety/rollback.
- TypeScript checking with incremental caching disabled.
- A 4,913-block grass/water/tree/building fixture: 29 meshes, 6,521 emitted faces, no unsupported states, valid WebP faces and visible downward terrain at all five viewpoints.
- The cached real Paper export: 17,738 blocks, 31 states, 71 meshes, 62,727 emitted faces, no unsupported states, valid map/cubemap images and visible downward terrain at all 12 viewpoints.
- A nonexistent block model failed with an explicit missing-assets error instead of completing an empty draft.
Both worker renders closed their Chromium processes automatically. No Minecraft server/client or website UI automation was started. Outputs and reports are under `.cache/renderer-regression`; `npm run test:renderer` reproduces the fixture, and a cached export folder can be supplied as an argument. The full 2,651,888-block dataset and Intel Metal/macOS behavior still require regeneration on the Mac. No speedup claim is inferred from the small Windows fixtures.

View File

@@ -10,8 +10,10 @@
"build": "next build", "build": "next build",
"start": "node scripts/supervise.mjs production", "start": "node scripts/supervise.mjs production",
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"reprocess": "node scripts/reprocess.mjs",
"worker": "tsx worker/main.ts", "worker": "tsx worker/main.ts",
"test": "tsx --test tests/*.test.ts", "test": "tsx --test tests/*.test.ts",
"test:renderer": "node scripts/test-renderer.mjs",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"renderer:proof": "node scripts/proof.mjs", "renderer:proof": "node scripts/proof.mjs",

View File

@@ -0,0 +1,28 @@
import sharp from "sharp";
// Empty sky faces are valid when looking up. A surface map or the view straight
// down from a surface capture must contain terrain, even if other faces are sky.
export async function assertTerrainImage(bytes, label) {
const { data, info } = await sharp(bytes)
.resize(64, 64, { fit: "fill" })
.toColourspace("srgb")
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const sky = [0x95, 0xc4, 0xeb];
let terrainPixels = 0;
for (let i = 0; i < data.length; i += info.channels) {
if (
Math.abs(data[i] - sky[0]) > 12 ||
Math.abs(data[i + 1] - sky[1]) > 12 ||
Math.abs(data[i + 2] - sky[2]) > 12
)
terrainPixels++;
}
if (terrainPixels < 4) {
throw new Error(
`${label} contains only sky. Check Minecraft asset loading, capture coordinates and graphics errors; this render will not be submitted as a playable map.`,
);
}
return terrainPixels;
}

View File

@@ -3,7 +3,8 @@ import { createServer } from "node:http";
import { readFile, writeFile, mkdir } from "node:fs/promises"; import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, sep } from "node:path"; import { resolve, sep } from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { assetsRoot } from "../scripts/assets.mjs"; import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot /** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output * @param {string} output
@@ -15,6 +16,12 @@ export async function renderSnapshot(
{ size = 1024, signal, onProgress } = {}, { size = 1024, signal, onProgress } = {},
) { ) {
const start = Date.now(); const start = Date.now();
if (signal?.aborted) throw new Error("Rendering cancelled");
await onProgress?.({
percent: 22,
message: "Checking Minecraft models and textures",
});
await prepareAssets();
await mkdir(output, { recursive: true }); await mkdir(output, { recursive: true });
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
try { try {
@@ -126,6 +133,10 @@ export async function renderSnapshot(
rendererPage.on("pageerror", (err) => rendererPage.on("pageerror", (err) =>
console.error("Renderer:", err.message), console.error("Renderer:", err.message),
); );
rendererPage.on("console", (message) => {
if (["warning", "error"].includes(message.type()))
console.error(`Renderer ${message.type()}: ${message.text()}`);
});
await rendererPage.goto(`http://127.0.0.1:${server.address().port}`); await rendererPage.goto(`http://127.0.0.1:${server.address().port}`);
await rendererPage.waitForFunction(() => window.rendererReady); await rendererPage.waitForFunction(() => window.rendererReady);
return rendererPage; return rendererPage;
@@ -169,6 +180,8 @@ export async function renderSnapshot(
{ p, face, size }, { p, face, size },
); );
const encoded = Buffer.from(data.split(",")[1], "base64"); const encoded = Buffer.from(data.split(",")[1], "base64");
if (face === "ny")
await assertTerrainImage(encoded, `Panorama ${p.id}, downward face`);
// Chromium encodes WebP once; retain a PNG fallback for canvas runtimes // Chromium encodes WebP once; retain a PNG fallback for canvas runtimes
// without WebP support rather than silently writing the wrong MIME type. // without WebP support rather than silently writing the wrong MIME type.
if (data.startsWith("data:image/webp;")) if (data.startsWith("data:image/webp;"))
@@ -191,6 +204,7 @@ export async function renderSnapshot(
await report({ percent: 85, message: "Rendering the surface map" }); await report({ percent: 85, message: "Rendering the surface map" });
const map = await page.evaluate(() => window.renderMap(1024)); const map = await page.evaluate(() => window.renderMap(1024));
const mapBytes = Buffer.from(map.split(",")[1], "base64"); const mapBytes = Buffer.from(map.split(",")[1], "base64");
await assertTerrainImage(mapBytes, "Surface map");
if (map.startsWith("data:image/webp;")) if (map.startsWith("data:image/webp;"))
await writeFile(`${output}/map.webp`, mapBytes); await writeFile(`${output}/map.webp`, mapBytes);
else else

View File

@@ -34,8 +34,8 @@ async function json(path) {
if (!jsonCache.has(path)) if (!jsonCache.has(path))
jsonCache.set( jsonCache.set(
path, path,
fetch(path).then((r) => { fetch(path, { signal: AbortSignal.timeout(60000) }).then((r) => {
if (!r.ok) throw new Error(path); if (!r.ok) throw new Error(`Minecraft asset ${path}: HTTP ${r.status}`);
return r.json(); return r.json();
}), }),
); );
@@ -183,8 +183,10 @@ window.buildTerrain = async (snapshot) => {
const bs = await json(resourcePath(name, "blockstates") + ".json"); const bs = await json(resourcePath(name, "blockstates") + ".json");
for (const v of selectModels(bs, props)) for (const v of selectModels(bs, props))
variants.push({ v, m: await model(v.model) }); variants.push({ v, m: await model(v.model) });
} catch { } catch (error) {
unsupported.add(name); unsupported.add(name);
if (unsupported.size <= 8)
console.warn(`Missing block model ${name}: ${error.message}`);
} }
stateCache.set(s, variants); stateCache.set(s, variants);
return variants; return variants;
@@ -225,6 +227,18 @@ const directions = {
[0, 1, 0], [0, 1, 0],
], ],
}; };
function renderScene(camera, requireGeometry = false) {
if (gl.isContextLost())
throw new Error("Graphics context lost while rendering terrain");
renderer.render(scene, camera);
const error = gl.getError();
if (error !== gl.NO_ERROR)
throw new Error(`WebGL failed to render terrain (error ${error})`);
if (requireGeometry && renderer.info.render.triangles === 0)
throw new Error(
"Surface-map camera sees no terrain geometry. Check world coordinates and Minecraft assets.",
);
}
window.renderFace = (p, face, size) => { window.renderFace = (p, face, size) => {
renderer.setSize(size, size); renderer.setSize(size, size);
const camera = new THREE.PerspectiveCamera(90, 1, 0.05, 512); const camera = new THREE.PerspectiveCamera(90, 1, 0.05, 512);
@@ -232,7 +246,7 @@ window.renderFace = (p, face, size) => {
const [dir, up] = directions[face]; const [dir, up] = directions[face];
camera.up.set(...up); camera.up.set(...up);
camera.lookAt(p.x + dir[0], p.y + dir[1], p.z + dir[2]); camera.lookAt(p.x + dir[0], p.y + dir[1], p.z + dir[2]);
renderer.render(scene, camera); renderScene(camera);
return renderer.domElement.toDataURL("image/webp", 0.85); return renderer.domElement.toDataURL("image/webp", 0.85);
}; };
window.renderMap = (size) => { window.renderMap = (size) => {
@@ -249,7 +263,7 @@ window.renderMap = (size) => {
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2); camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2);
camera.up.set(0, 0, -1); camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z); camera.lookAt(camera.position.x, 0, camera.position.z);
renderer.render(scene, camera); renderScene(camera, true);
return renderer.domElement.toDataURL("image/webp", 0.9); return renderer.domElement.toDataURL("image/webp", 0.9);
}; };
window.rendererReady = true; window.rendererReady = true;

View File

@@ -225,6 +225,11 @@ export async function buildTerrain(
); );
} }
const modelMs = performance.now() - started - indexMs; const modelMs = performance.now() - started - indexMs;
if (!materials.size || !entries.some(([, entry]) => entry.faces.length)) {
throw new Error(
`No terrain models could be rendered. Minecraft models/textures are missing or failed to load. Run npm run setup. Unsupported examples: ${[...unsupported].slice(0, 8).join(", ")}`,
);
}
await progress(35, "Building cached block faces", true); await progress(35, "Building cached block faces", true);
const batches = new Map(), const batches = new Map(),
meshList = [], meshList = [],
@@ -299,6 +304,11 @@ export async function buildTerrain(
); );
} }
window.terrainBounds = snapshot.bounds; window.terrainBounds = snapshot.bounds;
if (!meshList.length || !emittedFaces) {
throw new Error(
"Terrain renderer produced no visible geometry. The export cannot be accepted as a playable map.",
);
}
window.terrainMeshes = meshList; window.terrainMeshes = meshList;
const totalMs = performance.now() - started; const totalMs = performance.now() - started;
return { return {

View File

@@ -1,36 +1,90 @@
import { mkdir, readFile, writeFile, access } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
export const assetsRoot = ".cache/minecraft/1.21.11"; export const assetsRoot = ".cache/minecraft/1.21.11";
export async function prepareAssets() { const completionMarker = `${assetsRoot}/.minegeo-assets.json`;
const requiredAssets = [
"blockstates/grass_block.json",
"blockstates/stone.json",
"models/block/block.json",
"models/block/cube.json",
"models/block/cube_all.json",
"textures/block/grass_block_top.png",
"textures/block/dirt.png",
"textures/block/stone.png",
"textures/block/water_still.png",
];
async function assetsReady() {
try { try {
await access(`${assetsRoot}/assets/minecraft/blockstates/grass_block.json`); const marker = JSON.parse(await readFile(completionMarker, "utf8"));
return; if (
} catch {} marker.format !== "minegeo-assets/v1" ||
marker.minecraftVersion !== "1.21.11" ||
!marker.files
)
return false;
// A blockstate alone is insufficient: interrupted setup can leave models
// and textures absent. The marker is only written after all extraction.
for (const asset of requiredAssets) {
const bytes = await readFile(`${assetsRoot}/assets/minecraft/${asset}`);
if (asset.endsWith(".json")) JSON.parse(bytes.toString());
else if (
bytes.length < 8 ||
bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
)
return false;
}
return true;
} catch {
return false;
}
}
export async function prepareAssets() {
if (await assetsReady()) return;
console.log(
"Preparing Minecraft 1.21.11 models and textures for terrain rendering…",
);
const manifest = await ( const manifest = await (
await fetch( await fetch(
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
{ signal: AbortSignal.timeout(60000) },
) )
).json(); ).json();
const entry = manifest.versions.find((v) => v.id === "1.21.11"); const entry = manifest.versions.find((v) => v.id === "1.21.11");
if (!entry) throw new Error("Official Minecraft 1.21.11 metadata missing"); if (!entry) throw new Error("Official Minecraft 1.21.11 metadata missing");
const version = await (await fetch(entry.url)).json(); const version = await (
await fetch(entry.url, { signal: AbortSignal.timeout(60000) })
).json();
await mkdir(".cache/minecraft", { recursive: true }); await mkdir(".cache/minecraft", { recursive: true });
const jarPath = ".cache/minecraft/client-1.21.11.jar"; const jarPath = ".cache/minecraft/client-1.21.11.jar";
let jar; let jar;
try { try {
jar = await readFile(jarPath); jar = await readFile(jarPath);
} catch { } catch {
const response = await fetch(version.downloads.client.url); /* No cached client yet. */
if (!response.ok) throw new Error("Minecraft asset download failed");
jar = Buffer.from(await response.arrayBuffer());
await writeFile(jarPath, jar);
} }
if ( if (
!jar ||
createHash("sha1").update(jar).digest("hex") !== createHash("sha1").update(jar).digest("hex") !==
version.downloads.client.sha1 version.downloads.client.sha1
) ) {
throw new Error("Minecraft client checksum mismatch"); const response = await fetch(version.downloads.client.url, {
signal: AbortSignal.timeout(120000),
});
if (!response.ok)
throw new Error(
`Minecraft asset download failed: HTTP ${response.status}`,
);
jar = Buffer.from(await response.arrayBuffer());
if (
createHash("sha1").update(jar).digest("hex") !==
version.downloads.client.sha1
)
throw new Error(
"Minecraft client checksum mismatch; downloaded assets were not saved",
);
await writeFile(jarPath, jar);
}
const entries = unzipSync(jar, { const entries = unzipSync(jar, {
filter: (f) => filter: (f) =>
/^assets\/minecraft\/(models|blockstates|textures\/block)\//.test( /^assets\/minecraft\/(models|blockstates|textures\/block)\//.test(
@@ -42,6 +96,20 @@ export async function prepareAssets() {
await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
await writeFile(path, bytes); await writeFile(path, bytes);
} }
// Completion is recorded last, so a killed extraction is repaired next time.
await writeFile(
completionMarker,
JSON.stringify({
format: "minegeo-assets/v1",
minecraftVersion: "1.21.11",
clientSha1: version.downloads.client.sha1,
files: Object.keys(entries).length,
}),
);
if (!(await assetsReady()))
throw new Error(
"Minecraft rendering assets are incomplete or corrupt. Run npm run setup to repair them.",
);
console.log( console.log(
`Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`, `Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`,
); );

138
scripts/reprocess.mjs Normal file
View File

@@ -0,0 +1,138 @@
import { existsSync } from "node:fs";
import { createClient } from "@supabase/supabase-js";
import { prepareAssets } from "./assets.mjs";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const id = process.argv[2];
if (
!id ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
)
throw new Error(
"Usage: npm run reprocess -- <dataset UUID>. Stop the MineGeo worker first.",
);
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SECRET_KEY)
throw new Error("Configure Supabase in .env.local before reprocessing.");
const client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{
auth: { persistSession: false, autoRefreshToken: false },
},
);
function check(result) {
if (result.error) throw new Error(result.error.message);
return result.data;
}
const dataset = check(
await client.from("datasets").select("*").eq("id", id).single(),
);
if (
dataset.revision_id ||
!["draft", "review", "rejected", "failed"].includes(dataset.state)
)
throw new Error(
"Only an unpublished, inactive draft can be reprocessed. Published revisions cannot be changed.",
);
const job = check(
await client.from("upload_jobs").select("*").eq("dataset_id", id).single(),
);
if (!["complete", "failed", "cancelled"].includes(job.state))
throw new Error(
"This job is already queued or running. Stop the worker and wait for it to finish before retrying.",
);
const input = check(
await client
.from("dataset_inputs")
.select("validated,manifest")
.eq("dataset_id", id)
.single(),
);
if (dataset.source === "plugin") await prepareAssets();
// This maintenance command runs with the worker stopped. Compare-and-set
// guards reject intervening publication, ownership of a job, or another repair.
const changed = check(
await client
.from("datasets")
.update({
state: "processing",
rejection_reason: null,
map_path: null,
cover_path: null,
panorama_count: 0,
updated_at: new Date().toISOString(),
})
.eq("id", id)
.eq("state", dataset.state)
.is("revision_id", null)
.select("id"),
);
if (changed.length !== 1)
throw new Error(
"Dataset state changed; nothing was queued. Run the command again.",
);
try {
check(
await client
.from("dataset_inputs")
.update({ validated: false })
.eq("dataset_id", id),
);
const queued = check(
await client
.from("upload_jobs")
.update({
state: "queued",
attempts: 0,
progress: 0,
error: null,
lease_owner: null,
lease_until: null,
updated_at: new Date().toISOString(),
})
.eq("id", job.id)
.eq("state", job.state)
.eq("updated_at", job.updated_at)
.select("id"),
);
if (queued.length !== 1)
throw new Error(
"Job state changed while queuing; stop the worker before retrying.",
);
} catch (error) {
// A transport error can occur after an update committed. If the job is
// queued/running, leave its dataset processing; otherwise restore its draft.
const current = await client
.from("upload_jobs")
.select("state")
.eq("id", job.id)
.single();
if (!current.error && !["queued", "running"].includes(current.data.state)) {
check(
await client
.from("datasets")
.update({
state: dataset.state,
rejection_reason: dataset.rejection_reason,
map_path: dataset.map_path,
cover_path: dataset.cover_path,
panorama_count: dataset.panorama_count,
updated_at: new Date().toISOString(),
})
.eq("id", id)
.eq("state", "processing")
.is("revision_id", null),
);
check(
await client
.from("dataset_inputs")
.update({ validated: input.validated })
.eq("dataset_id", id),
);
}
throw error;
}
console.log(
`Queued dataset ${id} for a fresh render. Start MineGeo; no new export or upload is needed.`,
);

View File

@@ -7,6 +7,8 @@ await mkdir(".data", { recursive: true });
await mkdir("public/generated", { recursive: true }); await mkdir("public/generated", { recursive: true });
await mkdir("public/tools/mod", { recursive: true }); await mkdir("public/tools/mod", { recursive: true });
await mkdir("public/tools/plugin", { recursive: true }); await mkdir("public/tools/plugin", { recursive: true });
// Prepare terrain assets even if browser installation subsequently fails.
await prepareAssets();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const p = spawn( const p = spawn(
process.execPath, process.execPath,
@@ -19,7 +21,6 @@ await new Promise((resolve, reject) => {
: reject(new Error(`Chromium installation exited ${code}`)), : reject(new Error(`Chromium installation exited ${code}`)),
); );
}); });
await prepareAssets();
process.loadEnvFile(".env.local"); process.loadEnvFile(".env.local");
const configured = Boolean( const configured = Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_URL &&

140
scripts/test-renderer.mjs Normal file
View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
import sharp from "sharp";
import { renderSnapshot } from "../renderer/render.mjs";
import { assertTerrainImage } from "../renderer/image-validation.mjs";
// Exercise the product's rendering worker without launching a Minecraft server
// or driving the website. A cached real export can also be supplied as argv[2].
const output = ".cache/renderer-regression";
const snapshot = {
bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 },
coverage: [],
blocks: [],
};
for (let x = -24; x < 24; x++) {
for (let z = -24; z < 24; z++) {
snapshot.blocks.push([x, 63, z, "minecraft:dirt"]);
snapshot.blocks.push([
x,
64,
z,
x > 12 && z < -12
? "minecraft:water[level=0]"
: "minecraft:grass_block[snowy=false]",
]);
}
}
for (let x = -4; x <= 4; x++) {
for (let z = -4; z <= 4; z++) {
for (let y = 65; y <= 71; y++) {
if (Math.abs(x) === 4 || Math.abs(z) === 4 || y === 71) {
snapshot.blocks.push([
x,
y,
z,
y === 71 ? "minecraft:oak_planks" : "minecraft:stone_bricks",
]);
}
}
}
}
for (let y = 65; y <= 70; y++)
snapshot.blocks.push([-16, y, 0, "minecraft:oak_log[axis=y]"]);
for (let x = -18; x <= -14; x++) {
for (let z = -2; z <= 2; z++) {
snapshot.blocks.push([
x,
71,
z,
"minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]",
]);
}
}
snapshot.blocks.push([
5,
65,
0,
"minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]",
]);
let terrain = snapshot;
let points = [
[-12.5, 12.5],
[12.5, 12.5],
[12.5, -12.5],
[-12.5, -12.5],
[0.5, 18.5],
].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z }));
let source = "grass, dirt, water, tree and building regression fixture";
if (process.argv[2]) {
const directory = process.argv[2];
const manifest = JSON.parse(
await readFile(`${directory}/manifest.json`, "utf8"),
);
terrain = JSON.parse(
gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(),
);
points = manifest.viewpoints;
source = "cached Paper export (no server started)";
}
await mkdir(output, { recursive: true });
const report = await renderSnapshot(terrain, points, output, { size: 256 });
assert.ok(report.meshes > 0, "terrain must produce meshes");
assert.ok(report.emittedFaces > 0, "terrain must produce faces");
assert.ok(report.hiddenFaces > 0, "interior terrain faces must be culled");
assert.ok(report.geometryBytes > 0, "geometry must reach the renderer");
assert.equal(
report.unsupported.some((s) =>
/minecraft:(stone|dirt|grass_block|water)(\[|$)/.test(s),
),
false,
);
await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map");
for (const point of points) {
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
const metadata = await sharp(
await readFile(`${output}/${point.id}/${face}.webp`),
).metadata();
assert.equal(metadata.format, "webp");
assert.equal(metadata.width, 256);
assert.equal(metadata.height, 256);
}
await assertTerrainImage(
await readFile(`${output}/${point.id}/ny.webp`),
`Panorama ${point.id}, downward face`,
);
}
// The earlier renderer silently accepted this case with zero meshes and faces.
const missingModel = {
...snapshot,
blocks: [[0, 64, 0, "minecraft:minegeo_missing_model"]],
};
await assert.rejects(
renderSnapshot(missingModel, [points[0]], `${output}/missing-model`, {
size: 256,
}),
/No terrain models could be rendered/,
);
await writeFile(
`${output}/verification.json`,
JSON.stringify(
{
source,
...report,
checks: [
"nonempty terrain",
"valid WebP cubemap faces",
"surface visible below every viewpoint",
"missing model rejected",
],
},
null,
2,
),
);
console.log(
"Renderer regression passed:",
JSON.stringify({ source, ...report }),
);

View File

@@ -0,0 +1,141 @@
import { test, type TestContext } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve, sep } from "node:path";
import { createHash } from "node:crypto";
import { zipSync } from "fflate";
import sharp from "sharp";
import { prepareAssets, assetsRoot } from "../scripts/assets.mjs";
const png = await sharp({
create: { width: 16, height: 16, channels: 3, background: "#719447" },
})
.png()
.toBuffer();
const json = Buffer.from('{"variants":{},"elements":[],"textures":{}}');
const files = {
"blockstates/grass_block.json": json,
"blockstates/stone.json": json,
"models/block/block.json": json,
"models/block/cube.json": json,
"models/block/cube_all.json": json,
"textures/block/grass_block_top.png": png,
"textures/block/dirt.png": png,
"textures/block/stone.png": png,
"textures/block/water_still.png": png,
};
const jar = zipSync(
Object.fromEntries(
Object.entries(files).map(([path, bytes]) => [
`assets/minecraft/${path}`,
bytes,
]),
),
);
const checksum = createHash("sha1").update(jar).digest("hex");
async function isolatedCache(t: TestContext, corruptDownload = false) {
const previous = process.cwd();
const cacheParent = resolve(".cache");
await mkdir(cacheParent, { recursive: true });
const directory = await mkdtemp(`${cacheParent}/asset-tests-`);
process.chdir(directory);
t.after(async () => {
process.chdir(previous);
assert.ok(resolve(directory).startsWith(cacheParent + sep));
await rm(directory, { recursive: true, force: true });
});
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (input: string | URL | Request) => {
const url = String(input);
requests.push(url);
if (url.includes("version_manifest_v2"))
return Response.json({
versions: [{ id: "1.21.11", url: "https://fixture.invalid/version" }],
});
if (url.endsWith("/version"))
return Response.json({
downloads: {
client: { url: "https://fixture.invalid/client.jar", sha1: checksum },
},
});
if (url.endsWith("/client.jar"))
return new Response(
Buffer.from(corruptDownload ? "incomplete download" : jar),
);
throw new Error(`Unexpected request: ${url}`);
});
return requests;
}
test("missing vanilla cache is prepared completely and subsequent runs work offline", async (t) => {
const requests = await isolatedCache(t);
await prepareAssets();
assert.equal(requests.length, 3);
for (const [path, expected] of Object.entries(files)) {
assert.deepEqual(
await readFile(`${assetsRoot}/assets/minecraft/${path}`),
expected,
);
}
const marker = JSON.parse(
await readFile(`${assetsRoot}/.minegeo-assets.json`, "utf8"),
);
assert.equal(marker.files, Object.keys(files).length);
assert.equal(marker.clientSha1, checksum);
requests.length = 0;
await prepareAssets();
assert.deepEqual(requests, []);
});
test("an interrupted extraction with only a blockstate cannot masquerade as complete", async (t) => {
await isolatedCache(t);
await mkdir(`${assetsRoot}/assets/minecraft/blockstates`, {
recursive: true,
});
await writeFile(
`${assetsRoot}/assets/minecraft/blockstates/grass_block.json`,
json,
);
await prepareAssets();
assert.deepEqual(
await readFile(`${assetsRoot}/assets/minecraft/textures/block/stone.png`),
png,
);
});
test("a complete marker with a missing texture is repaired from the verified cached JAR", async (t) => {
const requests = await isolatedCache(t);
await prepareAssets();
const path = `${assetsRoot}/assets/minecraft/textures/block/stone.png`;
await rm(path);
requests.length = 0;
await prepareAssets();
assert.deepEqual(await readFile(path), png);
assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 0);
});
test("a corrupted cached client is downloaded again", async (t) => {
const requests = await isolatedCache(t);
await mkdir(".cache/minecraft", { recursive: true });
await writeFile(".cache/minecraft/client-1.21.11.jar", "corrupted");
await prepareAssets();
assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 1);
assert.equal(
createHash("sha1")
.update(await readFile(".cache/minecraft/client-1.21.11.jar"))
.digest("hex"),
checksum,
);
});
test("a failed checksum never creates a completion marker or saves a downloaded client", async (t) => {
await isolatedCache(t, true);
await assert.rejects(prepareAssets(), /checksum mismatch/);
await assert.rejects(readFile(`${assetsRoot}/.minegeo-assets.json`), {
code: "ENOENT",
});
await assert.rejects(readFile(".cache/minecraft/client-1.21.11.jar"), {
code: "ENOENT",
});
});

View File

@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import sharp from "sharp";
import { OpaqueIndex, QuadBuffer } from "../renderer/mesh-buffers.mjs";
import { assertTerrainImage } from "../renderer/image-validation.mjs";
test("opaque sections preserve negative coordinates and do not alias adjacent boundaries", () => {
const index = new OpaqueIndex(
{ minX: -32, minZ: -32, maxX: 32, maxZ: 32 },
-32,
32,
);
const expected = new Set<string>();
for (let x = -32; x <= 32; x++) {
for (let y = -32; y <= 32; y++) {
for (let z = -32; z <= 32; z++) {
if ((x * 3 + y * 7 + z * 11) % 23 === 0) {
index.add(x, y, z);
expected.add(`${x},${y},${z}`);
}
}
}
}
for (let x = -33; x <= 49; x++) {
for (let y = -33; y <= 49; y++) {
for (let z = -33; z <= 49; z++) {
assert.equal(index.has(x, y, z), expected.has(`${x},${y},${z}`));
}
}
}
index.clear();
assert.equal(index.has(0, 0, 0), false);
});
test("geometry batches retain every quad through growth with valid triangle indices and bounds", () => {
const buffer = new QuadBuffer();
const face = {
positions: new Float64Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]),
uvs: new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
normal: [0, 0, -1],
};
for (let i = 0; i < 4096; i++) buffer.add(face, i - 2048, 64, -1);
assert.equal(buffer.full, true);
assert.throws(() => buffer.add(face, 0, 0, 0), /full/);
const data = buffer.finish();
assert.equal(data.positions.length, 4096 * 12);
assert.equal(data.indices.length, 4096 * 6);
assert.equal(Math.max(...data.indices), 4096 * 4 - 1);
assert.deepEqual(
[...data.indices.slice(-6)],
[16380, 16381, 16382, 16382, 16381, 16383],
);
assert.deepEqual(data.min, [-2048, 64, -1]);
assert.deepEqual(data.max, [2048, 65, -1]);
});
test("sky-only WebP output is rejected instead of becoming a playable draft", async () => {
const sky = await sharp({
create: { width: 128, height: 128, channels: 3, background: "#95c4eb" },
})
.webp({ quality: 85 })
.toBuffer();
await assert.rejects(
assertTerrainImage(sky, "Surface map"),
/contains only sky/,
);
});
test("flat textured terrain is accepted even when its colors are nearly uniform", async () => {
const terrain = await sharp({
create: { width: 128, height: 128, channels: 3, background: "#719447" },
})
.webp()
.toBuffer();
assert.equal(
await assertTerrainImage(terrain, "Panorama p0, downward face"),
64 * 64,
);
});

144
tests/reprocess.test.ts Normal file
View File

@@ -0,0 +1,144 @@
import { test, type TestContext } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { resolve, sep } from "node:path";
const script = resolve("scripts/reprocess.mjs");
const id = "00000000-0000-4000-8000-000000000001";
const baseDataset = {
id,
source: "fabric",
state: "review",
revision_id: null,
rejection_reason: null,
map_path: "old-map.webp",
cover_path: "old-cover.webp",
panorama_count: 12,
};
const baseJob = {
id: "00000000-0000-4000-8000-000000000002",
state: "complete",
updated_at: "2026-10-04T12:00:00Z",
attempts: 3,
progress: 100,
error: null,
};
async function run(
t: TestContext,
options: {
published?: boolean;
active?: boolean;
jobConflict?: boolean;
} = {},
) {
const dataset = {
...baseDataset,
...(options.published ? { state: "published", revision_id: id } : {}),
};
const job = { ...baseJob, ...(options.active ? { state: "running" } : {}) };
const input = { validated: true, manifest: {} };
const patches: { table: string; body: Record<string, unknown> }[] = [];
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://localhost");
const table = url.pathname.split("/").at(-1)!;
const row =
table === "datasets" ? dataset : table === "upload_jobs" ? job : input;
res.setHeader("Content-Type", "application/json");
if (req.method === "GET") {
res.end(JSON.stringify(row));
return;
}
let raw = "";
for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw);
patches.push({ table, body });
if (options.jobConflict && table === "upload_jobs") {
res.end("[]");
return;
}
Object.assign(row, body);
res.end(
url.searchParams.has("select")
? JSON.stringify([{ id: table === "datasets" ? id : job.id }])
: "null",
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const parent = resolve(".cache");
await mkdir(parent, { recursive: true });
const directory = await mkdtemp(`${parent}/reprocess-tests-`);
t.after(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
assert.ok(resolve(directory).startsWith(parent + sep));
await rm(directory, { recursive: true, force: true });
});
const address = server.address();
assert.ok(address && typeof address !== "string");
const result = await new Promise<{ code: number | null; output: string }>(
(resolve, reject) => {
const child = spawn(process.execPath, [script, id], {
cwd: directory,
windowsHide: true,
env: {
...process.env,
NEXT_PUBLIC_SUPABASE_URL: `http://127.0.0.1:${address.port}`,
SUPABASE_SECRET_KEY: "test-only-secret",
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (data) => (output += data));
child.stderr.on("data", (data) => (output += data));
child.on("error", reject);
child.on("exit", (code) => resolve({ code, output }));
},
);
return { ...result, dataset, job, input, patches };
}
test("reprocessing keeps the upload and resets unpublished preview and exhausted job metadata", async (t) => {
const result = await run(t);
assert.equal(result.code, 0, result.output);
assert.equal(result.dataset.state, "processing");
assert.equal(result.dataset.map_path, null);
assert.equal(result.dataset.cover_path, null);
assert.equal(result.dataset.panorama_count, 0);
assert.equal(result.input.validated, false);
assert.equal(result.job.state, "queued");
assert.equal(result.job.attempts, 0);
assert.equal(result.job.progress, 0);
assert.equal(result.job.error, null);
assert.ok(
result.patches.every(({ table }) =>
["datasets", "upload_jobs", "dataset_inputs"].includes(table),
),
);
});
test("reprocessing cannot mutate published revisions", async (t) => {
const result = await run(t, { published: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /Published revisions cannot be changed/);
assert.deepEqual(result.patches, []);
});
test("reprocessing cannot take over an active worker job", async (t) => {
const result = await run(t, { active: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /already queued or running/);
assert.deepEqual(result.patches, []);
});
test("a queue conflict restores the previous draft metadata and validation status", async (t) => {
const result = await run(t, { jobConflict: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /Job state changed/);
assert.equal(result.dataset.state, baseDataset.state);
assert.equal(result.dataset.map_path, baseDataset.map_path);
assert.equal(result.dataset.panorama_count, baseDataset.panorama_count);
assert.equal(result.input.validated, true);
assert.equal(result.job.state, "complete");
});