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 function panoramaLocationKey(point: { x: number; y: number; z: number; }) { return [point.x, point.y, point.z].map(Math.floor).join(","); } 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, 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.contributionTo && new Set(manifest.viewpoints.map(panoramaLocationKey)).size < 5 ) throw new Error( "Capture at least five panoramas from different block positions", ); 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; export type Bounds = z.infer; export type Point = z.infer; 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 const difficultyRuleDefaults = { pan: true, compass: true, coordinates: true, mapZoom: true, scoring: "normal" as const, }; export type ScoringMode = "normal" | "strict" | "gentle"; export type DifficultyPreset = { id: string; name: string; seconds: number; zoom: boolean; // Optional for sessions created before expanded rules were introduced. pan?: boolean; compass?: boolean; coordinates?: boolean; mapZoom?: boolean; scoring?: ScoringMode; }; export const customDifficultySchema = z .object({ name: z .string() .trim() .refine( (name) => [...name].length >= 2 && [...name].length <= 40, "Difficulty names must contain 2–40 characters", ) .refine( (name) => !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name), "Difficulty names cannot contain hidden controls", ), seconds: z .number() .int() .min(15) .max(600) .refine((seconds) => seconds % 15 === 0), zoom: z.boolean(), pan: z.boolean().default(true), compass: z.boolean().default(true), coordinates: z.boolean().default(true), mapZoom: z.boolean().default(true), scoring: z.enum(["normal", "strict", "gentle"]).default("normal"), }) .strict(); export function scoreGuess( a: Pick, b: Pick, bounds: Bounds, scoring: ScoringMode = "normal", ) { const distance = Math.hypot(a.x - b.x, a.z - b.z); return { distance, score: distance <= 1 ? 5000 : Math.round( 5000 * Math.exp( (-(scoring === "strict" ? 8 : scoring === "gentle" ? 3 : 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][]; };