#!/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));