Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
267 lines
8.2 KiB
TypeScript
267 lines
8.2 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 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<Point, "x" | "z">,
|
||
b: Pick<Point, "x" | "z">,
|
||
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][];
|
||
};
|