Files
MineGeo/shared/protocol.ts
pavel444-byte 1a34eb71c2
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m30s
Require distinct panorama locations for rounds and captures
2026-10-16 10:09:36 +05:00

244 lines
7.4 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 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<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.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<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 type DifficultyPreset = {
id: string;
name: string;
seconds: number;
zoom: boolean;
};
export const customDifficultySchema = z
.object({
name: z
.string()
.trim()
.min(2)
.max(40)
.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(),
})
.strict();
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][];
};