Compare commits
2 Commits
94304a488c
...
891de77a10
| Author | SHA1 | Date | |
|---|---|---|---|
| 891de77a10 | |||
| 2cbaf45340 |
@@ -8,23 +8,35 @@ export async function GET(request: NextRequest) {
|
|||||||
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
||||||
const code = request.nextUrl.searchParams.get("code");
|
const code = request.nextUrl.searchParams.get("code");
|
||||||
const tokenHash = request.nextUrl.searchParams.get("token_hash");
|
const tokenHash = request.nextUrl.searchParams.get("token_hash");
|
||||||
|
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
|
||||||
const type = request.nextUrl.searchParams.get("type");
|
const type = request.nextUrl.searchParams.get("type");
|
||||||
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
||||||
if (
|
if (
|
||||||
code ||
|
code ||
|
||||||
|
legacyToken ||
|
||||||
(tokenHash &&
|
(tokenHash &&
|
||||||
(type === "email" || type === "recovery" || type === "magiclink"))
|
(type === "email" || type === "recovery" || type === "magiclink"))
|
||||||
) {
|
) {
|
||||||
const client = await authClient();
|
const client = await authClient();
|
||||||
const { data, error } = code
|
let { data, error } = code
|
||||||
? await client.auth.exchangeCodeForSession(code)
|
? await client.auth.exchangeCodeForSession(code)
|
||||||
: await client.auth.verifyOtp({
|
: legacyToken
|
||||||
token_hash: tokenHash!,
|
? await client.auth.exchangeCodeForSession(legacyToken)
|
||||||
type: type as "email" | "recovery" | "magiclink",
|
: await client.auth.verifyOtp({
|
||||||
});
|
token_hash: tokenHash!,
|
||||||
|
type: type as "email" | "recovery" | "magiclink",
|
||||||
|
});
|
||||||
|
// Older email templates could place a recovery token hash in /code=...
|
||||||
|
// instead of a PKCE code. Only Supabase can decide whether it is valid.
|
||||||
|
if (legacyToken && error)
|
||||||
|
({ data, error } = await client.auth.verifyOtp({
|
||||||
|
token_hash: legacyToken,
|
||||||
|
type: "recovery",
|
||||||
|
}));
|
||||||
if (!error && data.user) {
|
if (!error && data.user) {
|
||||||
await accountProfile(data.user);
|
await accountProfile(data.user);
|
||||||
const destination = type === "recovery" ? "/auth/reset-password" : next;
|
const destination =
|
||||||
|
type === "recovery" || legacyToken ? "/auth/reset-password" : next;
|
||||||
const response = NextResponse.redirect(new URL(destination, origin));
|
const response = NextResponse.redirect(new URL(destination, origin));
|
||||||
if (destination === "/auth/reset-password") {
|
if (destination === "/auth/reset-password") {
|
||||||
const { data: claims } = await client.auth.getClaims();
|
const { data: claims } = await client.auth.getClaims();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Recovery uses Supabase's verified code/token exchange and a ten-minute, HttpOnly
|
|||||||
|
|
||||||
No database migration is needed for these account changes: `profiles.display_name` already exists. Deploy the updated website, and configure `MINEGEO_URL` with its public HTTPS origin.
|
No database migration is needed for these account changes: `profiles.display_name` already exists. Deploy the updated website, and configure `MINEGEO_URL` with its public HTTPS origin.
|
||||||
|
|
||||||
In Supabase Auth, set the Site URL to that origin and allow its `/auth/callback` URL (plus any development callback URLs you actually use). Keep email confirmation enabled and configure working email delivery. PKCE email redirects use `/auth/callback`; token-hash templates can use `/auth/confirm?token_hash={{ .TokenHash }}&type=email`, or `type=recovery` for password resets. Custom confirmation templates should preserve the appropriate destination when returning to a pairing flow.
|
In Supabase Auth, set the Site URL to that origin and allow its `/auth/callback` URL (plus any development callback URLs you actually use). Keep email confirmation enabled and configure working email delivery. The recovery email template should link to `{{ .ConfirmationURL }}` so Supabase honors the `redirectTo` URL sent by MineGeo. A custom token-hash link may use `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery`. Do not append `/code=...` to the Site URL: it skips the callback route. PKCE email redirects use `/auth/callback`; custom confirmation templates should preserve the appropriate destination when returning to a pairing flow.
|
||||||
|
|
||||||
Set the provider's minimum password length to 12 as well, since direct Auth requests do not pass through application validation. Consider enabling secure password changes and leaked-password protection where your plan supports it. Provider hardening and SMTP settings are configured in Supabase; this change does not alter them automatically. See [password security](https://supabase.com/docs/guides/auth/password-security) and [redirect URL configuration](https://supabase.com/docs/guides/auth/redirect-urls).
|
Set the provider's minimum password length to 12 as well, since direct Auth requests do not pass through application validation. Consider enabling secure password changes and leaked-password protection where your plan supports it. Provider hardening and SMTP settings are configured in Supabase; this change does not alter them automatically. See [password security](https://supabase.com/docs/guides/auth/password-security) and [redirect URL configuration](https://supabase.com/docs/guides/auth/redirect-urls).
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
# Surface maps
|
# Surface maps
|
||||||
|
|
||||||
Plugin exports now produce a flat, north-up map rather than a textured aerial
|
Plugin exports produce a north-up, textured aerial view of the same Minecraft
|
||||||
render. The map uses up to 128 cells along its longest side, chooses the dominant
|
terrain used for panoramas. An orthographic camera looks straight down from
|
||||||
surface material in each cell, shades slopes and water depth, and retains the
|
Y=320. An angled sun adds shadows that show mountain slopes and structures;
|
||||||
original world bounds. Transparent glass and small vegetation do not hide the
|
panorama lighting is unchanged. Vanilla block models, textures, biome tints,
|
||||||
ground. Unknown blocks use stone gray; material colors are approximations.
|
buildings, trees and water appear in the map. The image keeps one world block
|
||||||
|
square, including rectangular exports, so the guessing pin still matches X/Z
|
||||||
|
coordinates. Exports up to 256 blocks across render at eight pixels per block;
|
||||||
|
larger exports are capped at 2048 pixels along their longest side.
|
||||||
|
|
||||||
Uncaptured terrain appears as parchment. The saved WebP uses lossless compression
|
Uncaptured terrain appears as parchment. The saved WebP uses high-quality
|
||||||
and nearest-neighbor scaling, with a subtle grid. Fabric maps retain their
|
compression. Fabric maps retain their client-captured colors and are compressed
|
||||||
client-captured colors and are also compressed losslessly. The web preview,
|
losslessly. The web preview, player guessing map and results share a parchment
|
||||||
player guessing map and results share a parchment frame.
|
frame.
|
||||||
|
|
||||||
New plugin uploads automatically use the new renderer. Existing processed images
|
New plugin uploads automatically use the new renderer. Existing processed images
|
||||||
are not regenerated merely by restarting the web server. For an unpublished
|
are not regenerated merely by restarting the web server. For an unpublished
|
||||||
@@ -24,4 +27,6 @@ panoramas or changing stored data:
|
|||||||
npm run render:map -- path/to/terrain.json.gz path/to/map.webp
|
npm run render:map -- path/to/terrain.json.gz path/to/map.webp
|
||||||
```
|
```
|
||||||
|
|
||||||
The parchment is a local SVG UI asset; no remote textures are required.
|
The parchment frame is a local SVG UI asset. Textured plugin maps use the
|
||||||
|
verified vanilla Minecraft asset cache prepared by `npm run setup` or by the
|
||||||
|
worker on demand.
|
||||||
|
|||||||
9
proxy.ts
9
proxy.ts
@@ -1,6 +1,15 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { legacyRecoveryToken } from "./shared/legacy-recovery";
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
|
const recoveryToken = legacyRecoveryToken(request.nextUrl.pathname);
|
||||||
|
if (recoveryToken) {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = "/auth/callback";
|
||||||
|
destination.search = "";
|
||||||
|
destination.searchParams.set("legacy_recovery_token", recoveryToken);
|
||||||
|
return NextResponse.redirect(destination);
|
||||||
|
}
|
||||||
let response = NextResponse.next({ request });
|
let response = NextResponse.next({ request });
|
||||||
if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response;
|
if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response;
|
||||||
const client = createServerClient(
|
const client = createServerClient(
|
||||||
|
|||||||
19
renderer/map-view.mjs
Normal file
19
renderer/map-view.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Keep one world block square in the image, including rectangular exports.
|
||||||
|
// Eight pixels per block preserves the vanilla textures in typical 128-block
|
||||||
|
// exports; large worlds are capped to a manageable image size.
|
||||||
|
export function mapDimensions(bounds) {
|
||||||
|
const spanX = bounds.maxX - bounds.minX;
|
||||||
|
const spanZ = bounds.maxZ - bounds.minZ;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(spanX) ||
|
||||||
|
!Number.isInteger(spanZ) ||
|
||||||
|
spanX < 1 ||
|
||||||
|
spanZ < 1
|
||||||
|
)
|
||||||
|
throw new Error("Surface map bounds must have a positive integer size");
|
||||||
|
const scale = Math.min(8, 2048 / Math.max(spanX, spanZ));
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(spanX * scale)),
|
||||||
|
height: Math.max(1, Math.round(spanZ * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { resolve, sep } from "node:path";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
|
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
|
||||||
import { assertTerrainImage } from "./image-validation.mjs";
|
import { assertTerrainImage } from "./image-validation.mjs";
|
||||||
import { renderSurfaceMap } from "./surface-map.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
|
||||||
@@ -203,7 +202,15 @@ export async function renderSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await report({ percent: 85, message: "Rendering the surface map" });
|
await report({ percent: 85, message: "Rendering the surface map" });
|
||||||
stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`);
|
const map = await page.evaluate(() => window.renderMap());
|
||||||
|
const mapBytes = Buffer.from(map.data.split(",")[1], "base64");
|
||||||
|
if (map.data.startsWith("data:image/webp;"))
|
||||||
|
await writeFile(`${output}/map.webp`, mapBytes);
|
||||||
|
else
|
||||||
|
await sharp(mapBytes)
|
||||||
|
.webp({ quality: 92 })
|
||||||
|
.toFile(`${output}/map.webp`);
|
||||||
|
stats.map = { width: map.width, height: map.height, cameraY: 320 };
|
||||||
await report({
|
await report({
|
||||||
percent: 90,
|
percent: 90,
|
||||||
message: "Rendered all panoramas and the surface map",
|
message: "Rendered all panoramas and the surface map",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from "/three.js";
|
import * as THREE from "/three.js";
|
||||||
import { buildTerrain } from "./terrain-mesh.js";
|
import { buildTerrain } from "./terrain-mesh.js";
|
||||||
import { firstTextureFrame } from "./block-colors.mjs";
|
import { firstTextureFrame } from "./block-colors.mjs";
|
||||||
|
import { mapDimensions } from "./map-view.mjs";
|
||||||
// This renderer consumes snapshots, never connects to a Minecraft server.
|
// This renderer consumes snapshots, never connects to a Minecraft server.
|
||||||
const renderer = new THREE.WebGLRenderer({
|
const renderer = new THREE.WebGLRenderer({
|
||||||
antialias: true,
|
antialias: true,
|
||||||
@@ -21,10 +22,12 @@ window.rendererInfo = {
|
|||||||
: "unavailable",
|
: "unavailable",
|
||||||
};
|
};
|
||||||
scene.background = new THREE.Color("#95c4eb");
|
scene.background = new THREE.Color("#95c4eb");
|
||||||
scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0));
|
const ambient = new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0);
|
||||||
|
scene.add(ambient);
|
||||||
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
|
const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
|
||||||
sun.position.set(-40, 90, -30);
|
sun.position.set(-40, 90, -30);
|
||||||
scene.add(sun);
|
scene.add(sun);
|
||||||
|
scene.add(sun.target);
|
||||||
const loader = new THREE.TextureLoader(),
|
const loader = new THREE.TextureLoader(),
|
||||||
jsonCache = new Map(),
|
jsonCache = new Map(),
|
||||||
textureCache = new Map(),
|
textureCache = new Map(),
|
||||||
@@ -142,7 +145,17 @@ function selectModels(state, props) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((p) => p.split("=")),
|
.map((p) => p.split("=")),
|
||||||
);
|
);
|
||||||
if (match(c, props)) return [Array.isArray(v) ? v[0] : v];
|
// Some exports omit default properties (for example snowy=false on a
|
||||||
|
// grass block). Match every property we do have, so the block remains
|
||||||
|
// visible instead of silently disappearing from the terrain and map.
|
||||||
|
if (
|
||||||
|
Object.entries(c).every(
|
||||||
|
([name, value]) =>
|
||||||
|
props[name] === undefined ||
|
||||||
|
String(value).split("|").includes(props[name]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [Array.isArray(v) ? v[0] : v];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (state.multipart || [])
|
return (state.multipart || [])
|
||||||
@@ -282,9 +295,10 @@ window.renderFace = (p, face, size) => {
|
|||||||
renderScene(camera);
|
renderScene(camera);
|
||||||
return renderer.domElement.toDataURL("image/webp", 0.85);
|
return renderer.domElement.toDataURL("image/webp", 0.85);
|
||||||
};
|
};
|
||||||
window.renderMap = (size) => {
|
window.renderMap = () => {
|
||||||
const b = window.terrainBounds;
|
const b = window.terrainBounds;
|
||||||
renderer.setSize(size, size);
|
const { width, height } = mapDimensions(b);
|
||||||
|
renderer.setSize(width, height);
|
||||||
const camera = new THREE.OrthographicCamera(
|
const camera = new THREE.OrthographicCamera(
|
||||||
-(b.maxX - b.minX) / 2,
|
-(b.maxX - b.minX) / 2,
|
||||||
(b.maxX - b.minX) / 2,
|
(b.maxX - b.minX) / 2,
|
||||||
@@ -293,10 +307,60 @@ window.renderMap = (size) => {
|
|||||||
0.1,
|
0.1,
|
||||||
1024,
|
1024,
|
||||||
);
|
);
|
||||||
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2);
|
camera.position.set((b.minX + b.maxX) / 2, 320, (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);
|
||||||
renderScene(camera, true);
|
const background = scene.background;
|
||||||
return renderer.domElement.toDataURL("image/webp", 0.9);
|
const previousSunPosition = sun.position.clone();
|
||||||
|
const previousSunTarget = sun.target.position.clone();
|
||||||
|
const previousAmbient = ambient.intensity;
|
||||||
|
const previousSunIntensity = sun.intensity;
|
||||||
|
const centerX = camera.position.x;
|
||||||
|
const centerZ = camera.position.z;
|
||||||
|
const radius = Math.hypot(b.maxX - b.minX, b.maxZ - b.minZ) / 2 + 256;
|
||||||
|
// The panorama renderer keeps its broad, shadowless lighting. For the map,
|
||||||
|
// an angled sun makes ridges, cliffs and buildings cast readable shadows.
|
||||||
|
ambient.intensity = 1.25;
|
||||||
|
sun.intensity = 2.2;
|
||||||
|
sun.position.set(centerX - 220, 300, centerZ - 180);
|
||||||
|
sun.target.position.set(centerX, 64, centerZ);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(4096, 4096);
|
||||||
|
sun.shadow.camera.left = -radius;
|
||||||
|
sun.shadow.camera.right = radius;
|
||||||
|
sun.shadow.camera.top = radius;
|
||||||
|
sun.shadow.camera.bottom = -radius;
|
||||||
|
sun.shadow.camera.near = 1;
|
||||||
|
sun.shadow.camera.far = radius * 4;
|
||||||
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
|
sun.shadow.bias = -0.0004;
|
||||||
|
sun.shadow.normalBias = 0.03;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||||
|
for (const mesh of window.terrainMeshes) {
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
}
|
||||||
|
scene.background = new THREE.Color("#ddc79f");
|
||||||
|
try {
|
||||||
|
renderScene(camera, true);
|
||||||
|
return {
|
||||||
|
data: renderer.domElement.toDataURL("image/webp", 0.92),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
renderer.shadowMap.enabled = false;
|
||||||
|
sun.castShadow = false;
|
||||||
|
for (const mesh of window.terrainMeshes) {
|
||||||
|
mesh.castShadow = false;
|
||||||
|
mesh.receiveShadow = false;
|
||||||
|
}
|
||||||
|
sun.position.copy(previousSunPosition);
|
||||||
|
sun.target.position.copy(previousSunTarget);
|
||||||
|
ambient.intensity = previousAmbient;
|
||||||
|
sun.intensity = previousSunIntensity;
|
||||||
|
scene.background = background;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.rendererReady = true;
|
window.rendererReady = true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFile, mkdir } from "node:fs/promises";
|
import { readFile, mkdir, mkdtemp, copyFile, rm } from "node:fs/promises";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve, sep } from "node:path";
|
||||||
import { gunzipSync } from "node:zlib";
|
import { gunzipSync } from "node:zlib";
|
||||||
import { renderSurfaceMap } from "../renderer/surface-map.mjs";
|
import { renderSnapshot } from "../renderer/render.mjs";
|
||||||
|
|
||||||
const [input, output] = process.argv.slice(2);
|
const [input, output] = process.argv.slice(2);
|
||||||
if (!input || !output)
|
if (!input || !output)
|
||||||
@@ -13,8 +13,17 @@ const terrain = JSON.parse(
|
|||||||
maxOutputLength: 256 * 1024 * 1024,
|
maxOutputLength: 256 * 1024 * 1024,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
);
|
);
|
||||||
await mkdir(dirname(resolve(output)), { recursive: true });
|
const parent = dirname(resolve(output));
|
||||||
const report = await renderSurfaceMap(terrain, output);
|
await mkdir(parent, { recursive: true });
|
||||||
console.log(
|
const temporary = await mkdtemp(resolve(parent, ".minegeo-map-"));
|
||||||
`Rendered ${report.width} × ${report.height} map cells from ${report.columns.toLocaleString()} surface columns: ${output}`,
|
try {
|
||||||
);
|
const report = await renderSnapshot(terrain, [], temporary);
|
||||||
|
await copyFile(resolve(temporary, "map.webp"), resolve(output));
|
||||||
|
console.log(
|
||||||
|
`Rendered ${report.map.width} × ${report.map.height} aerial map from Y=${report.map.cameraY}: ${output}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!temporary.startsWith(parent + sep))
|
||||||
|
throw new Error("Refusing to remove a map directory outside its parent");
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ for (let x = -24; x < 24; x++) {
|
|||||||
z,
|
z,
|
||||||
x > 12 && z < -12
|
x > 12 && z < -12
|
||||||
? "minecraft:water[level=0]"
|
? "minecraft:water[level=0]"
|
||||||
: "minecraft:grass_block[snowy=false]",
|
: x < -12
|
||||||
|
? "minecraft:grass_block"
|
||||||
|
: "minecraft:grass_block[snowy=false]",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,32 @@ assert.equal(
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map");
|
await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map");
|
||||||
|
const mapMeta = await sharp(`${output}/map.webp`).metadata();
|
||||||
|
assert.equal(mapMeta.width, report.map.width);
|
||||||
|
assert.equal(mapMeta.height, report.map.height);
|
||||||
|
assert.equal(report.map.cameraY, 320);
|
||||||
|
const { data: mapPixels, info: mapInfo } = await sharp(`${output}/map.webp`)
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const westGrass = (24 * mapInfo.width + 24) * mapInfo.channels;
|
||||||
|
assert.ok(
|
||||||
|
mapPixels[westGrass + 1] > mapPixels[westGrass],
|
||||||
|
"blocks without explicit default properties must appear on the aerial map",
|
||||||
|
);
|
||||||
|
const mapPixel = (x, z) => {
|
||||||
|
const pixelX = Math.floor(((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width);
|
||||||
|
const pixelY = Math.floor(((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height);
|
||||||
|
const offset = (pixelY * mapInfo.width + pixelX) * mapInfo.channels;
|
||||||
|
return mapPixels.subarray(offset, offset + 3);
|
||||||
|
};
|
||||||
|
if (!process.argv[2]) {
|
||||||
|
const shadedGrass = mapPixel(10, 9);
|
||||||
|
const sunlitGrass = mapPixel(10, -9);
|
||||||
|
assert.ok(
|
||||||
|
sunlitGrass[1] - shadedGrass[1] > 20,
|
||||||
|
"the raised building must cast a visible shadow on the aerial map",
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const point of points) {
|
for (const point of points) {
|
||||||
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
|
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
|
||||||
const metadata = await sharp(
|
const metadata = await sharp(
|
||||||
|
|||||||
6
shared/legacy-recovery.ts
Normal file
6
shared/legacy-recovery.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// A previously issued recovery email can land at /code=<token> when its email
|
||||||
|
// template omitted the callback route. Bring that link through verification.
|
||||||
|
export function legacyRecoveryToken(pathname: string) {
|
||||||
|
const match = /^\/code=([A-Za-z0-9._~-]{8,2048})$/.exec(pathname);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
14
tests/legacy-recovery.test.ts
Normal file
14
tests/legacy-recovery.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { legacyRecoveryToken } from "../shared/legacy-recovery";
|
||||||
|
|
||||||
|
test("legacy recovery email path yields only its code segment", () => {
|
||||||
|
assert.equal(legacyRecoveryToken("/code=abc12345-6789"), "abc12345-6789");
|
||||||
|
for (const path of [
|
||||||
|
"/code=short",
|
||||||
|
"/code=abc12345/other",
|
||||||
|
"/code=abc%2F12345",
|
||||||
|
"/auth/callback",
|
||||||
|
])
|
||||||
|
assert.equal(legacyRecoveryToken(path), null);
|
||||||
|
});
|
||||||
18
tests/map-view.test.ts
Normal file
18
tests/map-view.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mapDimensions } from "../renderer/map-view.mjs";
|
||||||
|
|
||||||
|
test("aerial map pixels keep rectangular X/Z bounds square", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mapDimensions({ minX: -32, maxX: 96, minZ: -16, maxZ: 48 }),
|
||||||
|
{ width: 1024, height: 512 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
mapDimensions({ minX: 0, maxX: 2048, minZ: 0, maxZ: 1024 }),
|
||||||
|
{ width: 2048, height: 1024 },
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => mapDimensions({ minX: 4, maxX: 4, minZ: 0, maxZ: 16 }),
|
||||||
|
/positive integer/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user