Files
MineGeo/shared/protocol.ts
pavel444-byte de0e891b62
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
Add Void difficulty and publish changelogs
2026-10-20 14:05:01 +05:00

285 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" },
void: {
seconds: 10,
zoom: false,
label: "Void",
pan: false,
compass: false,
coordinates: false,
mapZoom: false,
mapPan: false,
timerVisible: true,
scoring: "strict",
},
} as const;
export type Difficulty = keyof typeof difficulties;
export const difficultyRuleDefaults = {
pan: true,
compass: true,
coordinates: true,
mapZoom: true,
mapPan: true,
timerVisible: 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;
mapPan?: boolean;
timerVisible?: 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(5)
.max(600)
.refine((seconds) => seconds % 5 === 0),
zoom: z.boolean(),
pan: z.boolean().default(true),
compass: z.boolean().default(true),
coordinates: z.boolean().default(true),
mapZoom: z.boolean().default(true),
mapPan: z.boolean().default(true),
timerVisible: 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][];
};