544 lines
21 KiB
JavaScript
544 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
readFile,
|
|
readdir,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import {
|
|
assertRecastCollisionCoverage,
|
|
IDENTITY_CHUNK_TRANSFORM,
|
|
normalizeChunkTransform,
|
|
} from "./compound-stage.mjs";
|
|
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
|
|
async function exists(file) {
|
|
try {
|
|
await access(file);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJson(file) {
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
}
|
|
|
|
async function sha256(file) {
|
|
const hash = createHash("sha256");
|
|
hash.update(await readFile(file));
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async function inspectTriangleCount(file) {
|
|
const document = await new NodeIO().read(file);
|
|
return document.getRoot().listMeshes().reduce((total, mesh) => (
|
|
total + mesh.listPrimitives().reduce((meshTotal, primitive) => {
|
|
const indices = primitive.getIndices()?.getCount();
|
|
const positions = primitive.getAttribute("POSITION")?.getCount() ?? 0;
|
|
return meshTotal + Math.floor((indices ?? positions) / 3);
|
|
}, 0)
|
|
), 0);
|
|
}
|
|
|
|
function relativeProjectPath(file) {
|
|
return path.relative(projectRoot, file).replace(/\\/g, "/");
|
|
}
|
|
|
|
function rounded(point) {
|
|
return point.map((value) => Number(value.toFixed(6)));
|
|
}
|
|
|
|
async function navigationAnalysis(file, {
|
|
anchorComponentMinimumHeight = null,
|
|
anchorComponentMaximumHorizontalSpan = null,
|
|
semanticPositions = null,
|
|
} = {}) {
|
|
const document = await new NodeIO().read(file);
|
|
const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0];
|
|
const positions = primitive?.getAttribute("POSITION")?.getArray();
|
|
const sourceIndices = primitive?.getIndices()?.getArray();
|
|
if (!positions) throw new Error(`${relativeProjectPath(file)} has no navigation positions.`);
|
|
const indices = sourceIndices
|
|
? Array.from(sourceIndices)
|
|
: Array.from({ length: positions.length / 3 }, (_, index) => index);
|
|
const welded = new Map();
|
|
const vertexIds = [];
|
|
const uniquePoints = [];
|
|
for (let index = 0; index < positions.length; index += 3) {
|
|
const point = [positions[index], positions[index + 1], positions[index + 2]];
|
|
const key = point.map((value) => Math.round(value * 1000)).join(":");
|
|
if (!welded.has(key)) {
|
|
welded.set(key, welded.size);
|
|
uniquePoints.push(point);
|
|
}
|
|
vertexIds.push(welded.get(key));
|
|
}
|
|
const triangleCount = Math.floor(indices.length / 3);
|
|
const parent = Array.from({ length: triangleCount }, (_, index) => index);
|
|
const owner = new Map();
|
|
const root = (start) => {
|
|
let value = start;
|
|
while (parent[value] !== value) {
|
|
parent[value] = parent[parent[value]];
|
|
value = parent[value];
|
|
}
|
|
return value;
|
|
};
|
|
const union = (left, right) => {
|
|
const leftRoot = root(left);
|
|
const rightRoot = root(right);
|
|
if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot;
|
|
};
|
|
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
|
|
for (let corner = 0; corner < 3; corner += 1) {
|
|
const vertex = vertexIds[indices[triangle * 3 + corner]];
|
|
if (owner.has(vertex)) union(triangle, owner.get(vertex));
|
|
else owner.set(vertex, triangle);
|
|
}
|
|
}
|
|
const components = new Map();
|
|
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
|
|
const component = root(triangle);
|
|
if (!components.has(component)) components.set(component, []);
|
|
components.get(component).push(triangle);
|
|
}
|
|
const sortedComponents = [...components.values()].sort((left, right) => right.length - left.length);
|
|
const largest = sortedComponents[0];
|
|
if (!largest?.length) throw new Error(`${relativeProjectPath(file)} has no navigation triangles.`);
|
|
const componentVertices = (component) => {
|
|
const result = new Set();
|
|
for (const triangle of component) {
|
|
for (let corner = 0; corner < 3; corner += 1) {
|
|
result.add(vertexIds[indices[triangle * 3 + corner]]);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
const axisRange = (points, axis) => {
|
|
let minimum = Infinity;
|
|
let maximum = -Infinity;
|
|
for (const point of points) {
|
|
minimum = Math.min(minimum, point[axis]);
|
|
maximum = Math.max(maximum, point[axis]);
|
|
}
|
|
return [minimum, maximum];
|
|
};
|
|
const largestVertices = componentVertices(largest);
|
|
let anchorComponent = largest;
|
|
let anchorVertices = largestVertices;
|
|
let anchorSelection = "largest";
|
|
const selectionConstraints = [];
|
|
if (Number.isFinite(anchorComponentMinimumHeight)) {
|
|
selectionConstraints.push(`minimum-height:${anchorComponentMinimumHeight}`);
|
|
}
|
|
if (Number.isFinite(anchorComponentMaximumHorizontalSpan)) {
|
|
selectionConstraints.push(`maximum-horizontal-span:${anchorComponentMaximumHorizontalSpan}`);
|
|
}
|
|
if (selectionConstraints.length) {
|
|
for (const component of sortedComponents) {
|
|
const vertices = componentVertices(component);
|
|
const points = [...vertices].map((index) => uniquePoints[index]);
|
|
const minimumHeight = axisRange(points, 1)[0];
|
|
const horizontalX = axisRange(points, 0);
|
|
const horizontalZ = axisRange(points, 2);
|
|
const horizontalSpan = Math.max(
|
|
Math.abs(horizontalX[1] - horizontalX[0]),
|
|
Math.abs(horizontalZ[1] - horizontalZ[0]),
|
|
);
|
|
const satisfiesMinimumHeight = !Number.isFinite(anchorComponentMinimumHeight)
|
|
|| minimumHeight >= anchorComponentMinimumHeight;
|
|
const satisfiesMaximumSpan = !Number.isFinite(anchorComponentMaximumHorizontalSpan)
|
|
|| horizontalSpan <= anchorComponentMaximumHorizontalSpan;
|
|
if (satisfiesMinimumHeight && satisfiesMaximumSpan) {
|
|
anchorComponent = component;
|
|
anchorVertices = vertices;
|
|
anchorSelection = selectionConstraints.join(",");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const selectedVertices = new Set();
|
|
for (const triangle of anchorComponent) {
|
|
for (let corner = 0; corner < 3; corner += 1) {
|
|
selectedVertices.add(vertexIds[indices[triangle * 3 + corner]]);
|
|
}
|
|
}
|
|
const points = uniquePoints.filter((_, index) => selectedVertices.has(index));
|
|
const average = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
const ranges = [0, 1, 2].map((axis) => axisRange(points, axis));
|
|
const dominantAxis = [0, 2].sort((left, right) => (
|
|
(ranges[right][1] - ranges[right][0]) - (ranges[left][1] - ranges[left][0])
|
|
))[0];
|
|
const low = ranges[dominantAxis][0];
|
|
const high = ranges[dominantAxis][1];
|
|
const span = high - low;
|
|
const startBand = points.filter((point) => point[dominantAxis] < low + span * 0.08);
|
|
const endBand = points.filter((point) => point[dominantAxis] > high - span * 0.08);
|
|
const nearest = (target) => points.reduce((best, point) => (
|
|
Math.hypot(...point.map((value, axis) => value - target[axis]))
|
|
< Math.hypot(...best.map((value, axis) => value - target[axis]))
|
|
? point
|
|
: best
|
|
));
|
|
const centroid = [0, 1, 2].map((axis) => average(points.map((point) => point[axis])));
|
|
const startTarget = [0, 1, 2].map((axis) => average(startBand.map((point) => point[axis])));
|
|
const endTarget = [0, 1, 2].map((axis) => average(endBand.map((point) => point[axis])));
|
|
startTarget[dominantAxis] = low + span * 0.04;
|
|
endTarget[dominantAxis] = high - span * 0.04;
|
|
const semanticAnchors = semanticPositions
|
|
? Object.fromEntries(Object.entries(semanticPositions).map(([kind, point]) => [
|
|
kind,
|
|
rounded(nearest(point)),
|
|
]))
|
|
: undefined;
|
|
return {
|
|
triangles: triangleCount,
|
|
components: components.size,
|
|
largestComponentTriangles: largest.length,
|
|
largestComponentVertices: largestVertices.size,
|
|
anchorComponentTriangles: anchorComponent.length,
|
|
anchorComponentVertices: anchorVertices.size,
|
|
anchorSelection,
|
|
dominantAxis: dominantAxis === 0 ? "X" : "Z",
|
|
anchors: {
|
|
start: rounded(nearest(startTarget)),
|
|
middle: rounded(nearest(centroid)),
|
|
end: rounded(nearest(endTarget)),
|
|
},
|
|
...(semanticAnchors ? { semanticAnchors } : {}),
|
|
};
|
|
}
|
|
|
|
async function placementAnalysis(sourceDirectory) {
|
|
const files = (await readdir(sourceDirectory))
|
|
.filter((name) => name.endsWith("_ModelPlacementInformation.csv"))
|
|
.sort();
|
|
let rows = 0;
|
|
let unresolvedRows = 0;
|
|
const uniqueModels = new Set();
|
|
const unresolvedModels = new Set();
|
|
for (const name of files) {
|
|
const lines = (await readFile(path.join(sourceDirectory, name), "utf8"))
|
|
.split(/\r?\n/).slice(1).filter(Boolean);
|
|
for (const line of lines) {
|
|
const rawModel = line.split(";")[0];
|
|
const model = rawModel.startsWith("\"") && rawModel.endsWith("\"")
|
|
? rawModel.slice(1, -1).replace(/""/g, "\"")
|
|
: rawModel;
|
|
if (!model) continue;
|
|
rows += 1;
|
|
uniqueModels.add(model.toLowerCase());
|
|
const resolved = path.join(sourceDirectory, ...model.split(/[\\/]/));
|
|
if (!await exists(resolved)) {
|
|
unresolvedRows += 1;
|
|
unresolvedModels.add(model.toLowerCase());
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
files: files.length,
|
|
rows,
|
|
uniqueModels: uniqueModels.size,
|
|
unresolvedRows,
|
|
unresolvedModels: unresolvedModels.size,
|
|
};
|
|
}
|
|
|
|
function anchor(id, kind, sourcePosition, source, note) {
|
|
return {
|
|
id,
|
|
kind,
|
|
sourcePosition,
|
|
provenance: "authored",
|
|
source,
|
|
note,
|
|
};
|
|
}
|
|
|
|
function reportEntries(value) {
|
|
return Array.isArray(value) ? value : value ? [value] : [];
|
|
}
|
|
|
|
function epochAdtBasis(position) {
|
|
return [position[2], position[1], -position[0]];
|
|
}
|
|
|
|
function semanticRuntimePositions(runtimeDraft) {
|
|
if (!runtimeDraft?.entrance) return null;
|
|
const player = epochAdtBasis(runtimeDraft.entrance);
|
|
const mappedBosses = (runtimeDraft.bossCandidates ?? []).map((spawn) => (
|
|
epochAdtBasis(spawn.position)
|
|
));
|
|
const distanceSquared = (left, right) => (
|
|
(left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2
|
|
);
|
|
const boss = mappedBosses.sort((left, right) => (
|
|
distanceSquared(right, player) - distanceSquared(left, player)
|
|
))[0];
|
|
if (!boss) return null;
|
|
const middleTarget = player.map((value, axis) => (value + boss[axis]) / 2);
|
|
const mappedTrash = (runtimeDraft.spawns ?? [])
|
|
.filter((spawn) => !(runtimeDraft.bossCandidates ?? []).some(
|
|
(bossSpawn) => bossSpawn.id === spawn.id,
|
|
))
|
|
.map((spawn) => epochAdtBasis(spawn.position));
|
|
const trash = mappedTrash.sort((left, right) => (
|
|
distanceSquared(left, middleTarget) - distanceSquared(right, middleTarget)
|
|
))[0] ?? middleTarget;
|
|
return { player, trash, boss };
|
|
}
|
|
|
|
async function conversionAssetDescriptor({
|
|
conversion,
|
|
conversionFile,
|
|
entry,
|
|
index,
|
|
count,
|
|
role,
|
|
}) {
|
|
const file = path.join(path.dirname(conversionFile), entry.file);
|
|
if (!await exists(file)) throw new Error(`${conversion.dungeonId}: missing ${role} chunk ${entry.file}.`);
|
|
const actualTriangles = await inspectTriangleCount(file);
|
|
if (role !== "visual" && entry.triangles !== undefined && entry.triangles !== actualTriangles) {
|
|
throw new Error(
|
|
`${conversion.dungeonId}: ${role} chunk ${entry.file} reports ${entry.triangles} triangles but contains ${actualTriangles}.`,
|
|
);
|
|
}
|
|
const id = entry.id ?? (count === 1 ? role : `${role}-${String(index).padStart(3, "0")}`);
|
|
return {
|
|
id,
|
|
path: relativeProjectPath(file),
|
|
checksum: await sha256(file),
|
|
triangleCount: actualTriangles,
|
|
...(role === "visual" && entry.triangles !== undefined
|
|
? { instanceTriangleCount: entry.triangles }
|
|
: {}),
|
|
transform: normalizeChunkTransform(
|
|
entry.transform ?? IDENTITY_CHUNK_TRANSFORM,
|
|
`${conversion.dungeonId}: ${role} chunk ${id}`,
|
|
),
|
|
provenance: {
|
|
kind: "blender-conversion-report",
|
|
report: relativeProjectPath(conversionFile),
|
|
sourceFiles: entry.sources ?? conversion.sources ?? [],
|
|
...(entry.provenance ?? {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function scaffold(manastormSlug, dungeonSlug) {
|
|
const manastormRecipeFile = path.join(
|
|
projectRoot,
|
|
"dungeon-pipeline/manastorm/recipes",
|
|
`${manastormSlug}.json`,
|
|
);
|
|
const manastormRecipe = await readJson(manastormRecipeFile);
|
|
const dungeonRecipe = await readJson(path.join(
|
|
projectRoot,
|
|
"dungeon-pipeline/recipes",
|
|
`${dungeonSlug}.json`,
|
|
));
|
|
const work = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", dungeonSlug);
|
|
const sourceDirectory = path.join(work, "source-export");
|
|
const conversionFile = path.join(work, "staging/environment/conversion-report.json");
|
|
const recastFile = path.join(work, "recast-report.json");
|
|
const conversion = await readJson(conversionFile);
|
|
const recast = await readJson(recastFile);
|
|
if (!["green", "review-required"].includes(conversion.status)) {
|
|
throw new Error(`${dungeonSlug}: conversion is not reviewable.`);
|
|
}
|
|
const visualEntries = reportEntries(conversion.visual);
|
|
const collisionEntries = reportEntries(conversion.collision);
|
|
if (!visualEntries.length) throw new Error(`${dungeonSlug}: conversion has no visual chunks.`);
|
|
if (!collisionEntries.length) throw new Error(`${dungeonSlug}: conversion has no collision chunks.`);
|
|
const visualAssets = await Promise.all(visualEntries.map((entry, index) => conversionAssetDescriptor({
|
|
conversion,
|
|
conversionFile,
|
|
entry,
|
|
index,
|
|
count: visualEntries.length,
|
|
role: "visual",
|
|
})));
|
|
const collisionAssets = await Promise.all(collisionEntries.map((entry, index) => conversionAssetDescriptor({
|
|
conversion,
|
|
conversionFile,
|
|
entry,
|
|
index,
|
|
count: collisionEntries.length,
|
|
role: "collision",
|
|
})));
|
|
assertRecastCollisionCoverage(collisionAssets, recast, dungeonSlug);
|
|
const navigation = path.resolve(projectRoot, recast.output);
|
|
if (!await exists(navigation)) throw new Error(`${dungeonSlug}: navigation GLB is missing.`);
|
|
const navigationTriangles = await inspectTriangleCount(navigation);
|
|
if (recast.geometry?.triangles !== undefined && recast.geometry.triangles !== navigationTriangles) {
|
|
throw new Error(`${dungeonSlug}: navigation report triangle count does not match its GLB.`);
|
|
}
|
|
const navigationAsset = {
|
|
id: "navigation",
|
|
path: relativeProjectPath(navigation),
|
|
checksum: await sha256(navigation),
|
|
triangleCount: navigationTriangles,
|
|
transform: normalizeChunkTransform(
|
|
recast.transform ?? IDENTITY_CHUNK_TRANSFORM,
|
|
`${dungeonSlug}: navigation chunk`,
|
|
),
|
|
provenance: {
|
|
kind: "recast-navigation-report",
|
|
report: relativeProjectPath(recastFile),
|
|
collisionChunkIds: collisionAssets.map((asset) => asset.id),
|
|
collisionInputPaths: [...recast.inputChunks],
|
|
},
|
|
};
|
|
const automationFile = path.join(sourceDirectory, "automation-result.json");
|
|
const automation = await readJson(automationFile);
|
|
if (!automation.ok || automation.missingDependencies?.length) {
|
|
throw new Error(`${dungeonSlug}: source export has unresolved dependencies.`);
|
|
}
|
|
const sourceObjects = await Promise.all((conversion.sources ?? []).map(async (source) => {
|
|
const file = path.join(sourceDirectory, source);
|
|
if (!await exists(file)) throw new Error(`${dungeonSlug}: conversion source ${source} is missing.`);
|
|
return file;
|
|
}));
|
|
if (!sourceObjects.length) throw new Error(`${dungeonSlug}: conversion has no source objects.`);
|
|
const rootWmoRecords = (automation.manifest ?? []).filter((entry) => entry.type === "WMO");
|
|
const rootWmos = (await Promise.all(rootWmoRecords.map(async (entry) => {
|
|
const file = path.join(sourceDirectory, entry.file);
|
|
return await exists(file) ? file : null;
|
|
}))).filter(Boolean);
|
|
if (conversion.environmentMode === "global-wmo" && !rootWmos.length) {
|
|
throw new Error(`${dungeonSlug}: source WMO evidence is missing.`);
|
|
}
|
|
const runtimeDraftFile = path.join(work, "runtime-draft.json");
|
|
const runtimeDraft = await exists(runtimeDraftFile) ? await readJson(runtimeDraftFile) : null;
|
|
const semanticPositions = dungeonRecipe.clientBuild === "3.3.5a-Epoch"
|
|
&& conversion.environmentMode === "adt-hybrid"
|
|
? semanticRuntimePositions(runtimeDraft)
|
|
: null;
|
|
const nav = await navigationAnalysis(navigation, {
|
|
anchorComponentMinimumHeight: dungeonRecipe.navigation?.anchorComponentMinimumHeight,
|
|
anchorComponentMaximumHorizontalSpan:
|
|
dungeonRecipe.navigation?.anchorComponentMaximumHorizontalSpan,
|
|
semanticPositions,
|
|
});
|
|
const placements = await placementAnalysis(sourceDirectory);
|
|
const warnings = [
|
|
nav.anchorSelection === "largest"
|
|
? `The Recast result has ${nav.components} connected components; provisional anchors stay on the largest ${nav.largestComponentTriangles.toLocaleString("en-US")}-triangle component.`
|
|
: `The Recast result has ${nav.components} connected components; provisional anchors use the largest component satisfying ${nav.anchorSelection}, with ${nav.anchorComponentTriangles.toLocaleString("en-US")} triangles (the largest overall component has ${nav.largestComponentTriangles.toLocaleString("en-US")}).`,
|
|
"Entrance, boss, portal, and resurrection semantics require encounter-data and playtest review.",
|
|
...(semanticPositions
|
|
? ["Player, trash, and boss anchors were projected from AzerothCore server coordinates onto the selected Recast component."]
|
|
: []),
|
|
...(conversion.warnings ?? []),
|
|
...(dungeonRecipe.warnings ?? []),
|
|
];
|
|
const cappedCollision = collisionAssets.filter((asset) => asset.triangleCount >= 99_999);
|
|
if (cappedCollision.length) {
|
|
warnings.push(
|
|
`${cappedCollision.length} collision ${cappedCollision.length === 1 ? "chunk reached" : "chunks reached"} the 100,000-triangle conversion cap.`,
|
|
);
|
|
}
|
|
if (placements.unresolvedRows) {
|
|
warnings.push(
|
|
`${placements.unresolvedRows.toLocaleString("en-US")} of ${placements.rows.toLocaleString("en-US")} decorative placement rows reference ${placements.unresolvedModels.toLocaleString("en-US")} unavailable source-root OBJ paths; architectural WMO/collision completeness is unaffected, but decoration requires visual review.`,
|
|
);
|
|
}
|
|
const start = nav.semanticAnchors?.player ?? nav.anchors.start;
|
|
const middle = nav.semanticAnchors?.trash ?? nav.anchors.middle;
|
|
const end = nav.semanticAnchors?.boss ?? nav.anchors.end;
|
|
const anchorSource = semanticPositions
|
|
? "AzerothCore server coordinate projected to the selected Recast component."
|
|
: "Geometry-derived provisional Recast component anchor.";
|
|
const config = {
|
|
schemaVersion: 1,
|
|
slug: manastormSlug,
|
|
mapId: manastormRecipe.mapId,
|
|
reviewStatus: "provisional",
|
|
warnings,
|
|
coordinateSystem: {
|
|
units: "meters",
|
|
upAxis: "+Y",
|
|
fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
|
|
scale: 1,
|
|
},
|
|
sourcePackage: {
|
|
conversionReport: relativeProjectPath(conversionFile),
|
|
navigationReport: relativeProjectPath(recastFile),
|
|
omissions: automation.sourceOmissions ?? [],
|
|
fallbacks: automation.sourceFallbacks ?? [],
|
|
evidence: [
|
|
{ id: "wow-export-result", path: relativeProjectPath(automationFile) },
|
|
...sourceObjects.map((file, index) => ({
|
|
id: `wow-export-obj-${String(index).padStart(3, "0")}`,
|
|
path: relativeProjectPath(file),
|
|
})),
|
|
...rootWmos.map((file, index) => ({
|
|
id: `client-root-wmo-${String(index).padStart(3, "0")}`,
|
|
path: relativeProjectPath(file),
|
|
})),
|
|
{ id: "blender-conversion-report", path: relativeProjectPath(conversionFile) },
|
|
{ id: "recast-navigation-report", path: relativeProjectPath(recastFile) },
|
|
],
|
|
},
|
|
assets: {
|
|
visual: visualAssets,
|
|
collision: collisionAssets,
|
|
navigation: navigationAsset,
|
|
},
|
|
navigationAnalysis: nav,
|
|
placementAnalysis: placements,
|
|
anchors: [
|
|
anchor("player-provisional-main-component", "player", start, anchorSource, "Provisional projected entrance."),
|
|
anchor("trash-provisional-main-component", "trash", middle, anchorSource, "Provisional projected trash anchor."),
|
|
anchor("boss-provisional-main-component", "boss", end, anchorSource, "Provisional projected boss anchor."),
|
|
anchor("portal-provisional-main-component", "portal", start, "Reuses the provisional player anchor for the stage exit.", "Provisional until portal placement is playtested."),
|
|
anchor("resurrection-provisional-main-component", "resurrection", start, "Reuses the provisional player anchor as a safe navigation fallback.", "Provisional until resurrection placement is playtested."),
|
|
],
|
|
};
|
|
const output = path.join(
|
|
projectRoot,
|
|
"dungeon-pipeline/manastorm/imports",
|
|
`${manastormSlug}.json`,
|
|
);
|
|
await writeFile(output, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
return {
|
|
status: "green",
|
|
mapId: config.mapId,
|
|
slug: manastormSlug,
|
|
output: relativeProjectPath(output),
|
|
navigation: nav,
|
|
placements,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const [, , manastormSlug, dungeonSlug] = process.argv;
|
|
try {
|
|
if (!manastormSlug || !dungeonSlug) {
|
|
throw new Error("Usage: node scripts/manastorm-assets/scaffold-converted-import.mjs MANASTORM_SLUG DUNGEON_SLUG");
|
|
}
|
|
console.log(JSON.stringify(await scaffold(manastormSlug, dungeonSlug), null, 2));
|
|
} catch (error) {
|
|
console.error(JSON.stringify({
|
|
status: "error",
|
|
manastormSlug,
|
|
dungeonSlug,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}, null, 2));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main();
|
|
}
|