Files
2026-08-14 15:56:39 -04:00

169 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourceFile = path.join(projectRoot, "dungeon-pipeline", "epoch-five-player-instances.json");
const campaignFile = path.join(projectRoot, "dungeon-pipeline", "dungeon-campaign.json");
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const manastormRecipesRoot = path.join(
projectRoot,
"dungeon-pipeline",
"manastorm",
"recipes",
);
const stableValue = (value) => {
if (Array.isArray(value)) return value.map(stableValue);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
};
const stableJson = (value) => `${JSON.stringify(stableValue(value), null, 2)}\n`;
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const slugPart = (value) => String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "unknown";
const source = await readJson(sourceFile);
const campaign = await readJson(campaignFile);
if (source.schemaVersion !== 1 || !Array.isArray(source.dungeons)) {
throw new Error("Unsupported Epoch dungeon source manifest.");
}
const mapIds = new Set();
const slugs = new Set();
for (const dungeon of source.dungeons) {
if (!Number.isInteger(dungeon.mapId) || mapIds.has(dungeon.mapId)) {
throw new Error(`Duplicate or invalid Epoch map ID ${dungeon.mapId}.`);
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dungeon.slug) || slugs.has(dungeon.slug)) {
throw new Error(`Duplicate or invalid Epoch slug ${dungeon.slug}.`);
}
mapIds.add(dungeon.mapId);
slugs.add(dungeon.slug);
const recipe = {
schemaVersion: 1,
slug: dungeon.slug,
title: dungeon.title,
mapId: dungeon.mapId,
lfgDungeonIds: dungeon.lfgDungeonIds,
expansion: dungeon.expansion,
clientBuild: source.source.clientBuild,
mapDirectory: `World/Maps/${dungeon.mapDirectory}`,
environmentMode: "detect",
difficultyVariants: [
{
id: "normal",
difficulty: "normal",
mapDifficultyId: 0,
spawnMask: 1,
levelRange: dungeon.normalLevelRange,
},
{
id: "heroic",
difficulty: "heroic",
mapDifficultyId: 1,
spawnMask: 2,
levelRange: dungeon.heroicLevelRange,
},
],
clientSource: {
rootEnvironmentVariable: source.source.rootEnvironmentVariable,
relativeDefault: "../epoch_live",
},
serverSource: {
rootEnvironmentVariable: "WOW_SERVER_DATA",
adapter: "normalized-json",
readOnly: true,
},
extraction: { adtTiles: "all-present" },
navigation: {
cellSize: 0.3,
cellHeight: 0.15,
tileSize: 256,
retainTileIntermediates: false,
anchorComponentMaximumHorizontalSpan: 1600,
agentHeight: 1.8,
agentRadius: 0.36,
agentMaxClimb: 0.6,
agentMaxSlope: 50,
},
review: {
entrance: "required",
bossOrder: "required",
optionalEncounters: "required",
presentation: "required",
offMeshLinks: "required",
},
exceptions: [],
warnings: [
"Environment geometry is sourced from the installed Project Epoch 3.3.5a client.",
"Population, creature spells, patrols, and encounter credit identities are sourced from the pinned AzerothCore world snapshot.",
],
};
await writeFile(path.join(recipesRoot, `${dungeon.slug}.json`), stableJson(recipe), "utf8");
const manastormSlug = `${dungeon.mapId}-${slugPart(dungeon.mapDirectory)}`;
await writeFile(
path.join(manastormRecipesRoot, `${manastormSlug}.json`),
stableJson({
schemaVersion: 1,
id: `manastorm:asset-recipe:${dungeon.mapId}`,
slug: manastormSlug,
mapId: dungeon.mapId,
location: dungeon.title,
mapDirectory: dungeon.mapDirectory,
encounterIds: [],
sourceCandidates: [],
clientSnapshot: "project-epoch-installed-client",
shipping: {
visual: `${manastormSlug}/visual.glb`,
collision: `${manastormSlug}/collision.glb`,
navigation: `${manastormSlug}/navigation.glb`,
},
transform: {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
},
review: {
sourceSelection: "selected",
playerAnchor: "required",
trashAnchors: "required",
bossAnchor: "required",
portalAnchor: "required",
resurrectionAnchor: "required",
navmeshProjection: "required",
},
}),
"utf8",
);
}
const retained = campaign.dungeons.filter((entry) => !mapIds.has(entry.mapId));
const insertionIndex = retained.findIndex((entry) => entry.source === "ascension");
const additions = source.dungeons.map((dungeon) => ({
slug: dungeon.slug,
mapId: dungeon.mapId,
source: "epoch",
}));
campaign.dungeons.splice(
0,
campaign.dungeons.length,
...(insertionIndex < 0
? [...retained, ...additions]
: [...retained.slice(0, insertionIndex), ...additions, ...retained.slice(insertionIndex)]),
);
await writeFile(campaignFile, stableJson(campaign), "utf8");
console.log(JSON.stringify({
status: "complete",
campaignDungeons: campaign.dungeons.length,
epochDungeons: additions.length,
recipes: additions.map((entry) => `dungeon-pipeline/recipes/${entry.slug}.json`),
}, null, 2));