119 lines
6.4 KiB
JavaScript
119 lines
6.4 KiB
JavaScript
import { access, readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
|
|
const projectRoot = path.resolve(import.meta.dirname, "../..");
|
|
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
|
|
const failures = [];
|
|
const reports = [];
|
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
|
|
|
class UnionFind {
|
|
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
|
|
find(value) {
|
|
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
|
|
return this.parent[value];
|
|
}
|
|
union(left, right) {
|
|
const a = this.find(left);
|
|
const b = this.find(right);
|
|
if (a !== b) this.parent[b] = a;
|
|
}
|
|
}
|
|
|
|
function inspectConnectedNavigation(document, spawns) {
|
|
const vertices = [];
|
|
const triangles = [];
|
|
const byPosition = new Map();
|
|
for (const mesh of document.getRoot().listMeshes()) {
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
const positions = primitive.getAttribute("POSITION")?.getArray();
|
|
if (!positions) continue;
|
|
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
|
|
for (let offset = 0; offset < indices.length; offset += 3) {
|
|
const triangle = [];
|
|
for (let corner = 0; corner < 3; corner += 1) {
|
|
const index = indices[offset + corner];
|
|
const point = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
|
|
const key = point.map((value) => Math.round(value * 1000)).join(":");
|
|
if (!byPosition.has(key)) {
|
|
byPosition.set(key, vertices.length);
|
|
vertices.push(point);
|
|
}
|
|
triangle.push(byPosition.get(key));
|
|
}
|
|
triangles.push(triangle);
|
|
}
|
|
}
|
|
}
|
|
const union = new UnionFind(vertices.length);
|
|
for (const triangle of triangles) {
|
|
union.union(triangle[0], triangle[1]);
|
|
union.union(triangle[0], triangle[2]);
|
|
}
|
|
const components = new Set(vertices.map((_, index) => union.find(index)));
|
|
const spawnDistances = spawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => (
|
|
(point[0] - spawn[0]) ** 2 + (point[1] - spawn[1]) ** 2 + (point[2] - spawn[2]) ** 2
|
|
)))));
|
|
return { components: components.size, vertices: vertices.length, triangles: triangles.length, spawnDistances };
|
|
}
|
|
|
|
function validateVec3(value, label, stage) {
|
|
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) failures.push(`${stage.id}: invalid ${label}.`);
|
|
}
|
|
|
|
for (const stage of catalog.stages) {
|
|
validateVec3(stage.bounds?.min, "minimum bounds", stage);
|
|
validateVec3(stage.bounds?.max, "maximum bounds", stage);
|
|
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) validateVec3(spawn, `team spawn ${index}`, stage);
|
|
if (stage.teamSpawns?.length !== 2) failures.push(`${stage.id}: two team spawns are required.`);
|
|
if (stage.shadowSight?.length !== 2) failures.push(`${stage.id}: two Shadow Sight locations are required.`);
|
|
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) {
|
|
if (spawn.some((value, axis) => value < stage.bounds.min[axis] || value > stage.bounds.max[axis])) failures.push(`${stage.id}: team spawn ${index} is outside the sealed gameplay bounds.`);
|
|
}
|
|
for (const object of stage.lineOfSight ?? []) {
|
|
validateVec3(object.position, `${object.id} LOS position`, stage);
|
|
validateVec3(object.size, `${object.id} LOS size`, stage);
|
|
if (object.position[0] < stage.bounds.min[0] || object.position[0] > stage.bounds.max[0]
|
|
|| object.position[2] < stage.bounds.min[2] || object.position[2] > stage.bounds.max[2]) failures.push(`${stage.id}: ${object.id} LOS geometry is outside the gameplay bounds.`);
|
|
}
|
|
const dynamicIds = new Set((stage.dynamicObjects ?? []).map((object) => object.id));
|
|
for (const object of stage.lineOfSight ?? []) {
|
|
if (object.dynamicObjectId && !dynamicIds.has(object.dynamicObjectId)) failures.push(`${stage.id}: ${object.id} references a missing dynamic object.`);
|
|
}
|
|
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
|
|
const assets = {};
|
|
for (const name of ["visual", "collision", "navigation"]) {
|
|
const file = path.join(base, `${name}.glb`);
|
|
try {
|
|
await access(file);
|
|
const info = await stat(file);
|
|
const document = await io.read(file);
|
|
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
|
|
assets[name] = { bytes: info.size, triangles: Math.round(triangles) };
|
|
if (name === "navigation") {
|
|
const navigation = inspectConnectedNavigation(document, stage.teamSpawns);
|
|
assets[name].components = navigation.components;
|
|
assets[name].spawnDistances = navigation.spawnDistances.map((distance) => Math.round(distance * 1000) / 1000);
|
|
if (navigation.components !== 1) failures.push(`${stage.id}: rated navigation must contain one connected gameplay component.`);
|
|
for (const [index, distance] of navigation.spawnDistances.entries()) {
|
|
if (distance > 3) failures.push(`${stage.id}: team spawn ${index} is ${distance.toFixed(2)}m from the navigation surface.`);
|
|
}
|
|
}
|
|
if (!triangles) failures.push(`${stage.id}: ${name}.glb has no triangles.`);
|
|
} catch {
|
|
if (stage.validation.status === "validated") failures.push(`${stage.id}: required ${name}.glb is missing or unreadable.`);
|
|
}
|
|
}
|
|
if (assets.visual?.bytes > 25 * 1024 * 1024) failures.push(`${stage.id}: visual asset exceeds 25 MiB.`);
|
|
if (stage.validation.status === "validated" && ["traversal", "collision", "spawnSafety", "lineOfSight", "performance"].some((key) => stage.validation[key] !== true)) {
|
|
failures.push(`${stage.id}: validated stages must carry every validation flag.`);
|
|
}
|
|
reports.push({ id: stage.id, validation: stage.validation, assets });
|
|
}
|
|
|
|
console.log(JSON.stringify({ schemaVersion: 1, arenaCount: reports.length, failures, stages: reports }, null, 2));
|
|
if (failures.length) process.exitCode = 1;
|