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;