203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
import { z } from "zod";
|
|
import { normalizeServerAddress } from "./server-address";
|
|
export const boundsSchema = z
|
|
.object({
|
|
minX: z.number().int(),
|
|
minZ: z.number().int(),
|
|
maxX: z.number().int(),
|
|
maxZ: z.number().int(),
|
|
})
|
|
.refine(
|
|
(b) =>
|
|
b.maxX > b.minX &&
|
|
b.maxZ > b.minZ &&
|
|
b.maxX - b.minX <= 2048 &&
|
|
b.maxZ - b.minZ <= 2048,
|
|
"Bounds must be positive and at most 2048 blocks per side",
|
|
);
|
|
export const pointSchema = z.object({
|
|
x: z.number().finite(),
|
|
y: z.number().finite(),
|
|
z: z.number().finite(),
|
|
});
|
|
export const faces = ["px", "nx", "py", "ny", "pz", "nz"] as const;
|
|
export const viewpointSchema = pointSchema.extend({
|
|
id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
|
|
assetRoot: z.string().uuid().optional(),
|
|
assetId: z
|
|
.string()
|
|
.regex(/^[a-zA-Z0-9_-]{1,64}$/)
|
|
.optional(),
|
|
contributorId: z.string().uuid().optional(),
|
|
source: z.enum(["plugin", "fabric"]).optional(),
|
|
});
|
|
export function panoramaPath(
|
|
root: string,
|
|
point: z.infer<typeof viewpointSchema>,
|
|
file: string,
|
|
) {
|
|
return `${point.assetRoot ?? root}/${point.assetId ?? point.id}/${file}`;
|
|
}
|
|
export const datasetSchema = z
|
|
.object({
|
|
format: z.literal("minegeo-dataset/v1"),
|
|
minecraftVersion: z
|
|
.string()
|
|
.regex(
|
|
/^(?:1\.21\.11|26\.[12](?:\.\d+)*)$/,
|
|
"Unsupported Minecraft version",
|
|
),
|
|
dimension: z.literal("minecraft:overworld"),
|
|
source: z.enum(["plugin", "fabric"]),
|
|
// Portable map imports contain rendered images rather than capture inputs.
|
|
mediaFormat: z.literal("webp").optional(),
|
|
name: z.string().min(3).max(80),
|
|
description: z.string().max(2000).default(""),
|
|
serverName: z.string().min(2).max(80),
|
|
serverAddress: z.string().max(255).optional(),
|
|
bounds: boundsSchema,
|
|
north: z.literal("-z"),
|
|
contributionTo: z.string().uuid().optional(),
|
|
mapPath: z
|
|
.string()
|
|
.regex(/^[a-f0-9-]{36}\/maps\/[a-f0-9-]{36}\.webp$/)
|
|
.optional(),
|
|
viewpoints: z.array(viewpointSchema).min(1).max(24),
|
|
// Retain the capture's locations while viewpoints holds the saved selection.
|
|
availableViewpoints: z.array(viewpointSchema).min(1).max(24).optional(),
|
|
assets: z
|
|
.array(
|
|
z.object({
|
|
path: z
|
|
.string()
|
|
.regex(/^[a-zA-Z0-9_./-]+$/)
|
|
.refine((s) => !s.includes("..") && !s.startsWith("/")),
|
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
bytes: z
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.max(50 * 1024 * 1024),
|
|
}),
|
|
)
|
|
.max(4096),
|
|
})
|
|
.superRefine((d, ctx) => {
|
|
if (!d.contributionTo && d.viewpoints.length < 5)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "Maps need at least five panoramas",
|
|
});
|
|
const paths = new Set(d.assets.map((asset) => asset.path));
|
|
const requiredPaths =
|
|
d.mediaFormat === "webp"
|
|
? [
|
|
"map.webp",
|
|
...d.viewpoints.flatMap((point) =>
|
|
faces.map((face) => `${point.id}/${face}.webp`),
|
|
),
|
|
]
|
|
: d.source === "plugin"
|
|
? ["terrain.json.gz"]
|
|
: [
|
|
"map.png",
|
|
...(d.availableViewpoints ?? d.viewpoints)
|
|
.filter((point) => !point.assetRoot)
|
|
.flatMap((point) =>
|
|
faces.map((face) => `${point.id}/${face}.png`),
|
|
),
|
|
];
|
|
for (const path of requiredPaths) {
|
|
if (!paths.has(path))
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `Missing required export asset: ${path}`,
|
|
});
|
|
}
|
|
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, ...(d.availableViewpoints ?? [])])
|
|
if (
|
|
p.x < d.bounds.minX ||
|
|
p.x > d.bounds.maxX ||
|
|
p.z < d.bounds.minZ ||
|
|
p.z > d.bounds.maxZ
|
|
)
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "Viewpoint outside map bounds",
|
|
});
|
|
});
|
|
// Media provenance is assigned by the server after the owner accepts a contribution.
|
|
export function parseUploadManifest(value: unknown) {
|
|
const manifest = datasetSchema.parse(value);
|
|
if (manifest.mediaFormat)
|
|
throw new Error("Use map import for rendered archives");
|
|
manifest.serverAddress =
|
|
normalizeServerAddress(manifest.serverAddress) ?? undefined;
|
|
for (const p of [
|
|
...manifest.viewpoints,
|
|
...(manifest.availableViewpoints ?? []),
|
|
])
|
|
if (p.assetRoot || p.assetId || p.contributorId || p.source)
|
|
throw new Error("Uploads cannot specify server media provenance");
|
|
if (manifest.availableViewpoints)
|
|
throw new Error(
|
|
"Upload all captured viewpoints; selection happens in the dashboard",
|
|
);
|
|
if (manifest.mapPath)
|
|
throw new Error("Uploads cannot specify server map paths");
|
|
return manifest;
|
|
}
|
|
export type DatasetManifest = z.infer<typeof datasetSchema>;
|
|
export type Bounds = z.infer<typeof boundsSchema>;
|
|
export type Point = z.infer<typeof pointSchema>;
|
|
export const difficulties = {
|
|
easy: { seconds: 180, zoom: true, label: "Easy" },
|
|
normal: { seconds: 90, zoom: true, label: "Normal" },
|
|
hard: { seconds: 30, zoom: false, label: "Hard" },
|
|
} as const;
|
|
export type Difficulty = keyof typeof difficulties;
|
|
export function scoreGuess(
|
|
a: Pick<Point, "x" | "z">,
|
|
b: Pick<Point, "x" | "z">,
|
|
bounds: Bounds,
|
|
) {
|
|
const distance = Math.hypot(a.x - b.x, a.z - b.z);
|
|
return {
|
|
distance,
|
|
score:
|
|
distance <= 1
|
|
? 5000
|
|
: Math.round(
|
|
5000 *
|
|
Math.exp(
|
|
(-5 * distance) /
|
|
Math.hypot(
|
|
bounds.maxX - bounds.minX,
|
|
bounds.maxZ - bounds.minZ,
|
|
),
|
|
),
|
|
),
|
|
};
|
|
}
|
|
export type TerrainBlock = [number, number, number, string, string?];
|
|
export type TerrainSnapshot = {
|
|
bounds: Bounds;
|
|
blocks: TerrainBlock[];
|
|
coverage: [number, number][];
|
|
};
|