import { createHash } from "node:crypto"; import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { getBounds } from "@gltf-transform/functions"; const here = path.dirname(fileURLToPath(import.meta.url)); export const projectRoot = path.resolve(here, "../.."); export const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes"); export const fixturesRoot = path.join(projectRoot, "dungeon-pipeline", "fixtures"); export const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons")); export function 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])])); } export function stableJson(value) { return `${JSON.stringify(stableValue(value), null, 2)}\n`; } export async function writeJson(file, value) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, stableJson(value), "utf8"); } export async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } export async function exists(file) { try { await access(file); return true; } catch { return false; } } export async function sha256(file) { const hash = createHash("sha256"); hash.update(await readFile(file)); return hash.digest("hex"); } export function relativeProjectPath(file) { return path.relative(projectRoot, file).replace(/\\/g, "/"); } export async function loadRecipe(slug) { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid dungeon slug: ${slug}`); const file = path.join(recipesRoot, `${slug}.json`); if (!await exists(file)) throw new Error(`No DungeonSourceRecipe exists for "${slug}".`); const recipe = await readJson(file); const required = ["schemaVersion", "slug", "title", "mapId", "clientBuild", "mapDirectory", "environmentMode"]; const missing = required.filter((key) => recipe[key] === undefined); if (missing.length) throw new Error(`${slug}: recipe is missing ${missing.join(", ")}.`); if (recipe.schemaVersion !== 1) throw new Error(`${slug}: unsupported recipe schema ${recipe.schemaVersion}.`); if (recipe.slug !== slug) throw new Error(`${slug}: recipe slug is ${recipe.slug}.`); return recipe; } export function clientRootForRecipe(recipe) { const configuredVariable = recipe.clientSource?.rootEnvironmentVariable; const configuredRoot = configuredVariable ? process.env[configuredVariable] : undefined; return path.resolve( configuredRoot ?? process.env.WOW_CLIENT_ROOT ?? path.resolve(projectRoot, recipe.clientSource.relativeDefault), ); } function parseDbc(buffer, name) { if (buffer.subarray(0, 4).toString("ascii") !== "WDBC") throw new Error(`${name}.dbc is not WDBC data.`); const recordCount = buffer.readUInt32LE(4); const fieldCount = buffer.readUInt32LE(8); const recordSize = buffer.readUInt32LE(12); const stringBlockSize = buffer.readUInt32LE(16); if (recordSize !== fieldCount * 4) throw new Error(`${name}.dbc has unsupported record packing.`); const rows = []; for (let rowIndex = 0; rowIndex < recordCount; rowIndex += 1) { const offset = 20 + rowIndex * recordSize; const fields = Array.from({ length: fieldCount }, (_, fieldIndex) => buffer.readUInt32LE(offset + fieldIndex * 4)); rows.push(fields); } const stringsOffset = 20 + recordCount * recordSize; const stringAt = (offset) => { if (!offset || offset >= stringBlockSize) return ""; let end = stringsOffset + offset; while (end < buffer.length && buffer[end] !== 0) end += 1; return buffer.subarray(stringsOffset + offset, end).toString("utf8"); }; return { name, recordCount, fieldCount, recordSize, stringBlockSize, rows, stringAt }; } async function walkForFile(root, fileName, depth = 4) { if (depth < 0 || !await exists(root)) return []; const entries = await readdir(root, { withFileTypes: true }); const files = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { if (entry.name.toLowerCase().startsWith("notused")) continue; const next = path.join(root, entry.name); if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) files.push(next); if (entry.isDirectory()) files.push(...await walkForFile(next, fileName, depth - 1)); } return files; } async function locateDbc(clientRoot, name, override, extractedRoot, sharedRoot) { const candidates = [ extractedRoot ? path.join(extractedRoot, "DBFilesClient", `${name}.dbc`) : null, sharedRoot ? path.join(sharedRoot, "DBFilesClient", `${name}.dbc`) : null, override ? path.resolve(clientRoot, override) : null, path.join(clientRoot, "Data", "patch-A.MPQ", "DBFilesClient", `${name}.dbc`), path.join(clientRoot, "Data", "DBFilesClient", `${name}.dbc`), ].filter(Boolean); for (const candidate of candidates) if (await exists(candidate)) return candidate; return (await walkForFile(path.join(clientRoot, "Data"), `${name}.dbc`))[0] ?? null; } const clientTables = Object.freeze([ "Map", "LFGDungeons", "MapDifficulty", "CreatureDisplayInfo", "CreatureDisplayInfoExtra", "CreatureModelData", "ItemDisplayInfo", "GameObjectDisplayInfo", "AnimationData", ]); const clientTableCache = new Map(); async function cachedClientTable(clientRoot, name, override, extractedRoot, sharedRoot) { const key = `${clientRoot}\0${name}\0${override ?? ""}\0${sharedRoot ?? ""}`; if (!clientTableCache.has(key)) { clientTableCache.set(key, (async () => { const file = await locateDbc(clientRoot, name, override, extractedRoot, sharedRoot); if (!file) return null; const buffer = await readFile(file); return { file, table: parseDbc(buffer, name), hash: createHash("sha256").update(buffer).digest("hex"), }; })()); } return clientTableCache.get(key); } const requiredServerTables = Object.freeze([ "creature", "creature_template", "creature_template_model", "creature_addon", "creature_formations", "waypoint_data", "gameobject", "gameobject_template", "instance_encounters", "areatrigger_teleport", ]); function matchingRows(table, recipe) { if (table.name === "Map") return table.rows.filter((row) => row[0] === recipe.mapId); if (table.name === "MapDifficulty") return table.rows.filter((row) => row[1] === recipe.mapId); if (table.name === "LFGDungeons") return table.rows.filter((row) => row[23] === recipe.mapId || recipe.lfgDungeonIds.includes(row[0])); if (table.name === "AnimationData") { const semantic = /^(Stand|Walk|Run|Attack|Death|Drown|SpellCast|CombatWound|CombatCritical)/i; return table.rows.filter((row) => semantic.test(table.stringAt(row[1]))); } return []; } function serializeClientRows(table, rows) { return rows.map((fields) => ({ id: fields[0], fields, strings: Object.fromEntries(fields.flatMap((value, index) => { const string = table.stringAt(value); return string ? [[index, string]] : []; })), })); } async function readServerData(recipe) { const configured = process.env.WOW_SERVER_DATA; if (!configured) return { data: null, sourceFile: null, blocker: "WOW_SERVER_DATA is not set." }; const resolved = path.resolve(configured); if (!await exists(resolved)) return { data: null, sourceFile: null, blocker: "WOW_SERVER_DATA does not exist." }; const info = await stat(resolved); let selected = info.isDirectory() ? null : resolved; if (info.isDirectory()) { for (const candidate of [path.join(resolved, `${recipe.slug}.json`), path.join(resolved, "server-data.json")]) { if (await exists(candidate)) { selected = candidate; break; } } } if (!selected || !await exists(selected)) return { data: null, sourceFile: null, blocker: "No normalized server JSON was found in WOW_SERVER_DATA." }; if (recipe.serverSource.adapter !== "normalized-json") { return { data: null, sourceFile: selected, blocker: `Server adapter ${recipe.serverSource.adapter} is not implemented; export normalized read-only JSON first.` }; } return { data: await readJson(selected), sourceFile: selected, blocker: null }; } function normalizedServerSnapshot(data, mapId) { const tables = data?.tables ?? data ?? {}; const missing = requiredServerTables.filter((name) => !Array.isArray(tables[name])); const creatures = (tables.creature ?? []).filter((row) => Number(row.map ?? row.mapId) === mapId); const gameObjects = (tables.gameobject ?? []).filter((row) => Number(row.map ?? row.mapId) === mapId); const entries = new Set([ ...creatures.map((row) => Number(row.id1 ?? row.entry ?? row.creatureId)), ...(tables.instance_encounters ?? []).map( (row) => Number(row.creatureEntry ?? row.creditEntry), ), ].filter(Number.isFinite)); const guids = new Set(creatures.map((row) => Number(row.guid)).filter(Number.isFinite)); const templateFilter = (row) => entries.has(Number(row.entry ?? row.creatureId ?? row.CreatureID)); const guidFilter = (row) => guids.has(Number(row.guid ?? row.memberGUID ?? row.linkedGuid)); return { missing, revision: data.revision ?? data.databaseRevision ?? null, schemaHash: data.schemaHash ?? null, tables: { creature: creatures, creature_template: (tables.creature_template ?? []).filter(templateFilter), creature_template_model: (tables.creature_template_model ?? []).filter(templateFilter), creature_addon: (tables.creature_addon ?? []).filter(guidFilter), creature_formations: (tables.creature_formations ?? []).filter(guidFilter), waypoint_data: tables.waypoint_data ?? [], gameobject: gameObjects, gameobject_template: tables.gameobject_template ?? [], instance_encounters: (tables.instance_encounters ?? []).filter((row) => Number(row.map ?? row.mapId) === mapId), areatrigger_teleport: (tables.areatrigger_teleport ?? []).filter((row) => Number(row.target_map ?? row.targetMap) === mapId), }, }; } function rowValue(row, names, fallback = undefined) { for (const name of names) if (row?.[name] !== undefined && row[name] !== null) return row[name]; return fallback; } function serverPosition(row) { const wow = [ Number(rowValue(row, ["position_x", "positionX", "x"], 0)), Number(rowValue(row, ["position_y", "positionY", "y"], 0)), Number(rowValue(row, ["position_z", "positionZ", "z"], 0)), ]; return [-wow[0], wow[2], wow[1]]; } function float32(value) { const buffer = new ArrayBuffer(4); new DataView(buffer).setUint32(0, Number(value) >>> 0, true); return new DataView(buffer).getFloat32(0, true); } function generateRuntimeDraft(recipe, server, parsedClientTables) { const tables = server.tables; const templates = new Map(tables.creature_template.map((row) => [Number(rowValue(row, ["entry", "creatureId", "CreatureID"])), row])); const templateModels = new Map(); for (const row of tables.creature_template_model) { const entry = Number(rowValue(row, ["CreatureID", "entry", "creatureId"])); if (!templateModels.has(entry)) templateModels.set(entry, []); templateModels.get(entry).push(row); } const displayTable = parsedClientTables.get("CreatureDisplayInfo"); const displayExtraTable = parsedClientTables.get("CreatureDisplayInfoExtra"); const modelTable = parsedClientTables.get("CreatureModelData"); const displays = new Map(displayTable?.rows.map((fields) => [fields[0], fields]) ?? []); const displayExtras = new Map( displayExtraTable?.rows.map((fields) => [fields[0], fields]) ?? [], ); const models = new Map(modelTable?.rows.map((fields) => [fields[0], fields]) ?? []); const encounterEntries = new Set(tables.instance_encounters .map((row) => Number(rowValue(row, ["creatureEntry", "creditEntry", "entry"]))) .filter(Boolean)); const entityEntries = [...new Set([ ...tables.creature.map((spawn) => Number( rowValue(spawn, ["id1", "entry", "creatureId"]), )), ...encounterEntries, ])]; const entities = []; const creatureSources = []; const sourceByEntry = new Map(); for (const entry of entityEntries) { const template = templates.get(entry) ?? {}; const modelRow = templateModels.get(entry)?.[0]; const displayId = Number(rowValue(modelRow, ["CreatureDisplayID", "displayId", "DisplayID"], rowValue(template, ["modelid1", "displayId"], 0))); const display = displays.get(displayId); const modelId = display?.[1] ?? 0; const model = models.get(modelId); const modelPath = model ? modelTable.stringAt(model[2]) : ""; const textures = display ? [display[6], display[7], display[8]].map((offset) => displayTable.stringAt(offset)).filter(Boolean) : []; const normalizedModelPath = modelPath.replace(/\//g, "\\").replace(/\.mdx$/i, ".m2"); const modelDirectory = path.win32.dirname(normalizedModelPath); const normalizedTextures = textures.map((texture) => { const normalized = texture.replace(/\//g, "\\"); const withExtension = path.win32.extname(normalized) ? normalized : `${normalized}.blp`; return /[\\/]/.test(withExtension) ? withExtension : path.win32.join(modelDirectory, withExtension); }); const extraId = display?.[3] ?? 0; const extra = displayExtras.get(extraId); const bakedTextureName = extra ? displayExtraTable.stringAt(extra[20]) : ""; const bakedTexture = bakedTextureName ? `Textures\\BakedNpcTextures\\${bakedTextureName}` : ""; const source = { slug: `display-${displayId}`, name: String(rowValue(template, ["name", "Name"], `Creature ${entry}`)), entry, displayId, displayScale: display?.[4] ? float32(display[4]) : 1, rigType: extraId > 0 ? "character-composite" : "m2", modelPath: normalizedModelPath, skinTextures: normalizedTextures, ...(extraId > 0 ? { extraId, bakedTexture, compositeAppearance: { raceId: extra?.[1] ?? 0, sexId: extra?.[2] ?? 0, skinId: extra?.[3] ?? 0, faceId: extra?.[4] ?? 0, hairStyleId: extra?.[5] ?? 0, hairColorId: extra?.[6] ?? 0, facialHairId: extra?.[7] ?? 0, equipmentDisplayIds: extra?.slice(8, 19).filter(Boolean) ?? [], }, ...(!bakedTexture ? { unresolvedComposite: true } : {}), } : {}), spellcaster: Boolean(rowValue(template, ["spell1", "spell2", "spell3", "spell4"], 0)), }; sourceByEntry.set(entry, source); creatureSources.push(source); entities.push({ id: `entry-${entry}`, entry, name: source.name, displayId, level: [rowValue(template, ["minlevel", "minLevel"]), rowValue(template, ["maxlevel", "maxLevel"])], faction: rowValue(template, ["faction", "faction_A", "factionAlliance"]), spells: [1, 2, 3, 4, 5, 6, 7, 8].map((index) => rowValue(template, [`spell${index}`])).filter(Boolean), rank: Number(rowValue(template, ["rank", "Rank"], 0)), baseAttackTimeMs: Number(rowValue(template, ["BaseAttackTime", "baseattacktime"], 2_000)), rangeAttackTimeMs: Number(rowValue(template, ["RangeAttackTime", "rangeattacktime"], 2_000)), walkSpeed: Number(rowValue(template, ["speed_walk", "speedWalk"], 1)), runSpeed: Number(rowValue(template, ["speed_run", "speedRun"], 1.14286)), unitFlags: Number(rowValue(template, ["unit_flags", "unitFlags"], 0)), flagsExtra: Number(rowValue(template, ["flags_extra", "flagsExtra"], 0)), scriptName: rowValue(template, ["ScriptName", "scriptName"], ""), }); } const addons = new Map(tables.creature_addon.map((row) => [Number(rowValue(row, ["guid"])), row])); const waypointGroups = new Map(); for (const row of tables.waypoint_data) { const id = Number(rowValue(row, ["id", "pathId", "path_id"])); if (!waypointGroups.has(id)) waypointGroups.set(id, []); waypointGroups.get(id).push(row); } const spawns = tables.creature.map((row) => { const guid = Number(rowValue(row, ["guid"])); const entry = Number(rowValue(row, ["id1", "entry", "creatureId"])); const addon = addons.get(guid); const pathId = Number(rowValue(addon, ["path_id", "pathId"], 0)); const waypoints = (waypointGroups.get(pathId) ?? []) .sort((left, right) => Number(rowValue(left, ["point", "pointId"], 0)) - Number(rowValue(right, ["point", "pointId"], 0))) .map(serverPosition); return { id: `creature-${guid}`, guid, entityId: `entry-${entry}`, position: serverPosition(row), yaw: -Number(rowValue(row, ["orientation", "yaw"], 0)), spawnMask: Number(rowValue(row, ["spawnMask", "spawn_mask"], 1)), phaseMask: Number(rowValue(row, ["phaseMask", "phase_mask"], 1)), movementType: rowValue(row, ["MovementType", "movementType"]), ...(waypoints.length ? { pathId, waypoints } : {}), }; }); const bosses = spawns.filter((spawn) => encounterEntries.has(Number(spawn.entityId.replace("entry-", "")))); const entranceRow = tables.areatrigger_teleport[0]; return { schemaVersion: 1, dungeonId: recipe.slug, coordinateTransform: "three(x,y,z)=(-wow.x,wow.z,wow.y)", entranceCandidates: tables.areatrigger_teleport.map((row) => ({ id: rowValue(row, ["ID", "id"]), name: rowValue(row, ["Name", "name"], "Entrance candidate"), position: serverPosition({ position_x: rowValue(row, ["target_position_x", "targetX", "x"]), position_y: rowValue(row, ["target_position_y", "targetY", "y"]), position_z: rowValue(row, ["target_position_z", "targetZ", "z"]), }), yaw: -Number(rowValue(row, ["target_orientation", "targetOrientation"], 0)), })), entrance: entranceRow ? serverPosition({ position_x: rowValue(entranceRow, ["target_position_x", "targetX", "x"]), position_y: rowValue(entranceRow, ["target_position_y", "targetY", "y"]), position_z: rowValue(entranceRow, ["target_position_z", "targetZ", "z"]), }) : null, entities, spawns, formations: tables.creature_formations, gameObjects: tables.gameobject, gameObjectTemplates: tables.gameobject_template, bossCandidates: bosses, encounterRows: tables.instance_encounters, creatureSources, blockers: creatureSources.filter((source) => !source.displayId || !source.modelPath || source.unresolvedComposite) .map((source) => source.unresolvedComposite ? `${source.name} display ${source.displayId} requires composite resolution from CreatureDisplayInfoExtra.` : `${source.name} has no resolvable display/model.`), }; } export async function discoverDungeon(slug) { const recipe = await loadRecipe(slug); const output = path.join(workRoot, slug); const clientRoot = clientRootForRecipe(recipe); const blockers = []; const warnings = []; const tables = {}; const parsedClientTables = new Map(); const archiveHashes = {}; for (const name of clientTables) { let cached; try { cached = await cachedClientTable( clientRoot, name, recipe.extraction?.dbcOverrides?.[name], path.join(output, "client-dbc"), recipe.clientBuild === "3.3.5a-Epoch" ? path.join(workRoot, "epoch-client") : null, ); } catch (error) { blockers.push(`${name}.dbc could not be parsed: ${error.message}`); continue; } if (!cached) { blockers.push(`Missing merged client table DBFilesClient/${name}.dbc.`); continue; } try { const { table } = cached; parsedClientTables.set(name, table); const rows = matchingRows(table, recipe); tables[name] = { header: { recordCount: table.recordCount, fieldCount: table.fieldCount, recordSize: table.recordSize, stringBlockSize: table.stringBlockSize, }, rows: serializeClientRows(table, rows), }; archiveHashes[`DBFilesClient/${name}.dbc`] = cached.hash; if (["Map", "LFGDungeons"].includes(name) && rows.length === 0) blockers.push(`${name}.dbc has no row for map ${recipe.mapId}.`); } catch (error) { blockers.push(`${name}.dbc could not be parsed: ${error.message}`); } } const serverResult = await readServerData(recipe); let server = null; if (serverResult.blocker) blockers.push(serverResult.blocker); if (serverResult.data) { server = normalizedServerSnapshot(serverResult.data, recipe.mapId); blockers.push(...server.missing.map((name) => `Server snapshot is missing table ${name}.`)); if (!server.tables.creature.length) blockers.push(`Server snapshot has no creature spawns for map ${recipe.mapId}.`); } const sourceSnapshot = { schemaVersion: 1, dungeonId: slug, mapId: recipe.mapId, environmentMode: recipe.environmentMode, coordinateTransform: "three(x,y,z)=(-wow.x,wow.z,wow.y)", client: { build: recipe.clientBuild, tables }, server, }; const provenance = { schemaVersion: 1, dungeonId: slug, clientBuild: recipe.clientBuild, clientArchiveHashes: archiveHashes, database: serverResult.sourceFile ? { adapter: recipe.serverSource.adapter, contentHash: await sha256(serverResult.sourceFile), revision: server?.revision, schemaHash: server?.schemaHash, } : { adapter: recipe.serverSource.adapter }, }; const overrides = { schemaVersion: 1, dungeonId: slug, reviewRequired: { entrance: recipe.review.entrance !== "approved", bossOrder: recipe.review.bossOrder !== "approved", optionalEncounters: recipe.review.optionalEncounters !== "approved", presentation: recipe.review.presentation !== "approved", offMeshLinks: recipe.review.offMeshLinks !== "approved", }, entrance: null, bossOrder: [], optionalEncounters: [], unsupportedDoorsTransports: [], offMeshLinks: [], inferredFormations: [], }; const report = { schemaVersion: 1, dungeonId: slug, status: blockers.length ? "blocked" : "green", blockers: [...new Set(blockers)].sort(), warnings: [...new Set(warnings)].sort(), outputs: ["source-snapshot.json", "provenance.lock.json", "overrides.review.json"], }; const runtimeDraft = server ? generateRuntimeDraft(recipe, server, parsedClientTables) : null; if (runtimeDraft?.blockers.length) { report.blockers = [...new Set([...report.blockers, ...runtimeDraft.blockers])].sort(); report.status = "blocked"; } const outputs = [ writeJson(path.join(output, "source-snapshot.json"), sourceSnapshot), writeJson(path.join(output, "provenance.lock.json"), provenance), writeJson(path.join(output, "overrides.review.json"), overrides), writeJson(path.join(output, "discovery-report.json"), report), ]; if (runtimeDraft) { outputs.push(writeJson(path.join(output, "runtime-draft.json"), runtimeDraft)); outputs.push(writeJson(path.join(output, "creature-export.recipe.json"), { schemaVersion: 1, slug, title: recipe.title, creatures: runtimeDraft.creatureSources, })); } await Promise.all(outputs); return report; } async function fixtureFor(slug) { const file = path.join(fixturesRoot, slug, "runtime-fixture.json"); if (!await exists(file)) throw new Error(`${slug}: runtime fixture is missing; discovery/build has not generated reviewed content.`); return readJson(file); } function firstAnimation(animations, pattern) { return animations.find((name) => pattern.test(name)); } function semanticAnimations(document) { const names = document.getRoot().listAnimations().map((animation) => animation.getName()).filter(Boolean); return Object.fromEntries([ ["stand", firstAnimation(names, /^Stand\b/i)], ["walk", firstAnimation(names, /^Walk\b/i)], ["run", firstAnimation(names, /^Run\b/i)], ["attack", firstAnimation(names, /^Attack(?!.*Ready)/i)], ["cast", firstAnimation(names, /^(SpellCast|Cast)/i)], ["hit", firstAnimation(names, /^(StandWound|CombatWound|CombatCritical)/i)], ["death", firstAnimation(names, /^(Death|Drown)\b/i)], ].filter(([, name]) => name)); } function creatureMaterialRoles(document) { return Object.fromEntries(document.getRoot().listMaterials().map((material, index) => { const name = material.getName() || `material-${index}`; const alphaMode = material.getAlphaMode(); return [name, alphaMode === "BLEND" ? "blend" : alphaMode === "MASK" ? "cutout" : "opaque"]; })); } async function buildCreatureDefinitions(slug, recipe, fixture, blockers) { const definitions = []; for (const creature of fixture.creatures) { const source = recipe.creatures?.find((candidate) => candidate.slug === creature.id); if (!source) { blockers.push(`${creature.id} has no resolved display/model source record.`); continue; } const file = path.resolve(projectRoot, creature.path); if (!await exists(file)) continue; const document = await new NodeIO().read(file); const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; if (!scene) { blockers.push(`${creature.id} has no GLB scene.`); continue; } const bounds = getBounds(scene); const compositeRecipeHash = source.rigType === "character-composite" ? createHash("sha256").update(stableJson({ displayId: source.displayId, modelPath: source.modelPath, bakedTexture: source.bakedTexture, hairTexture: source.hairTexture, geosets: source.geosets, equipment: source.equipment, })).digest("hex") : undefined; const key = compositeRecipeHash ? `composite-${compositeRecipeHash}` : `display-${source.displayId}`; const info = await stat(file); const definition = { schemaVersion: 1, id: key, displayId: source.displayId, ...(compositeRecipeHash ? { compositeRecipeHash } : {}), sourceM2: source.modelPath.replace(/\\/g, "/"), sourceTextures: [...(source.skinTextures ?? []), source.bakedTexture, source.hairTexture] .filter(Boolean).map((value) => value.replace(/\\/g, "/")), rigType: source.rigType, bounds: { min: Array.from(bounds.min), max: Array.from(bounds.max) }, scale: source.displayScale, groundOffset: creature.groundOffset ?? Math.max(0, -bounds.min[1]), facing: { axis: "+x", runtimeRotationY: creature.rotationY ?? -Math.PI / 2 }, materialRoles: creatureMaterialRoles(document), equipment: (source.equipment ?? []).map((item) => ({ ...item, modelPath: item.modelPath.replace(/\\/g, "/") })), animations: semanticAnimations(document), fallbackExceptions: source.fallbackExceptions ?? [], assetUrl: `/${creature.path.replace(/^public[\\/]/, "").replace(/\\/g, "/")}`, checksum: await sha256(file), size: info.size, }; definitions.push(definition); await writeJson(path.join(workRoot, "shared-creatures", `${key}.json`), definition); } await writeJson(path.join(workRoot, slug, "creature-assets.manifest.json"), { schemaVersion: 1, dungeonId: slug, creatures: definitions.sort((left, right) => left.id.localeCompare(right.id)), }); return definitions; } export async function buildDungeonPack(slug) { const recipe = await loadRecipe(slug); const fixture = await fixtureFor(slug); const output = path.join(workRoot, slug); const blockers = [...(fixture.blockers ?? [])]; const assets = []; for (const asset of fixture.assets) { const file = path.resolve(projectRoot, asset.path); if (!await exists(file)) { blockers.push(`Missing ${asset.role} asset ${asset.path}.`); continue; } const info = await stat(file); assets.push({ ...asset, size: info.size, checksum: await sha256(file) }); } for (const creature of fixture.creatures) { const file = path.resolve(projectRoot, creature.path); if (!await exists(file)) blockers.push(`Missing creature asset ${creature.path}.`); } const creatureDefinitions = await buildCreatureDefinitions(slug, recipe, fixture, blockers); const definitionChecksum = createHash("sha256").update(stableJson(fixture)).digest("hex"); const manifest = { schemaVersion: 1, dungeonId: slug, definitionChecksum, generatedAt: "deterministic", environmentMode: recipe.environmentMode, assets: assets.sort((left, right) => left.path.localeCompare(right.path)), creatureDependencies: creatureDefinitions.map((creature) => creature.id).sort(), reports: ["discovery-report.json", "creature-assets.manifest.json", "validation-report.json"], blockers: blockers.sort(), }; await writeJson(path.join(output, "dungeon-pack.manifest.json"), manifest); return { status: blockers.length ? "blocked" : "green", blockers, manifest }; } function finiteArray(array) { return !array || Array.from(array).every(Number.isFinite); } function triangleCount(document) { return document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((meshSum, primitive) => { const indices = primitive.getIndices()?.getCount(); const positions = primitive.getAttribute("POSITION")?.getCount() ?? 0; return meshSum + Math.floor((indices ?? positions) / 3); }, 0), 0); } async function validateGltf(file, role, blockers) { try { const document = await new NodeIO().read(file); for (const accessor of document.getRoot().listAccessors()) { if (!finiteArray(accessor.getArray())) blockers.push(`${relativeProjectPath(file)} has non-finite accessor ${accessor.getName() || ""}.`); } const triangles = triangleCount(document); if (role === "collision" && triangles > 100_000) blockers.push(`${relativeProjectPath(file)} has ${triangles} collision triangles; limit is 100000 per chunk.`); return { document, triangles }; } catch (error) { blockers.push(`${relativeProjectPath(file)} is not a readable GLB: ${error.message}`); return null; } } function validateCreature(document, creature, blockers) { const root = document.getRoot(); if (!root.listSkins().length) blockers.push(`${creature.id} has no weighted skin.`); const names = root.listNodes().map((node) => node.getName()).filter(Boolean); const duplicates = names.filter((name, index) => names.indexOf(name) !== index); if (duplicates.length) blockers.push(`${creature.id} has duplicate node names: ${[...new Set(duplicates)].join(", ")}.`); const clips = root.listAnimations(); const clipNames = clips.map((animation) => animation.getName()); const required = [ ["Stand", /^Stand\b/i], ["locomotion", /^(Walk|Run)\b/i], ["primary attack", /^Attack(?!.*Ready)/i], ["Death", /^(Death|Drown)\b/i], ]; if (creature.spellcaster) required.push(["Cast", /^(SpellCast|Cast)/i]); for (const [semantic, pattern] of required) if (!clipNames.some((name) => pattern.test(name))) blockers.push(`${creature.id} is missing ${semantic}.`); for (const animation of clips) { for (const sampler of animation.listSamplers()) { if (!finiteArray(sampler.getInput()?.getArray()) || !finiteArray(sampler.getOutput()?.getArray())) { blockers.push(`${creature.id} has non-finite tracks in ${animation.getName()}.`); } } } } function navigationComponents(document) { const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; const positions = primitive?.getAttribute("POSITION")?.getArray(); const sourceIndices = primitive?.getIndices()?.getArray(); if (!positions) return null; const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index); const welded = new Map(); const vertexIds = []; for (let index = 0; index < positions.length; index += 3) { const key = `${Math.round(positions[index] * 1000)}:${Math.round(positions[index + 1] * 1000)}:${Math.round(positions[index + 2] * 1000)}`; if (!welded.has(key)) welded.set(key, welded.size); vertexIds.push(welded.get(key)); } const triangles = []; const owners = new Map(); for (let offset = 0; offset + 2 < indices.length; offset += 3) { const source = [indices[offset], indices[offset + 1], indices[offset + 2]]; const ids = source.map((id) => vertexIds[id]); const centroid = source.reduce((sum, id) => [sum[0] + positions[id * 3], sum[1] + positions[id * 3 + 1], sum[2] + positions[id * 3 + 2]], [0, 0, 0]).map((value) => value / 3); const triangle = { ids, centroid, neighbors: new Set(), component: -1 }; const triangleId = triangles.length; triangles.push(triangle); for (const edge of [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]]) { const key = [...edge].sort((a, b) => a - b).join(":"); const previous = owners.get(key); if (previous !== undefined) { triangles[previous].neighbors.add(triangleId); triangle.neighbors.add(previous); } else owners.set(key, triangleId); } } let component = 0; for (let start = 0; start < triangles.length; start += 1) { if (triangles[start].component >= 0) continue; const queue = [start]; triangles[start].component = component; while (queue.length) { const current = queue.pop(); for (const neighbor of triangles[current].neighbors) if (triangles[neighbor].component < 0) { triangles[neighbor].component = component; queue.push(neighbor); } } component += 1; } return { triangles, componentCount: component }; } function nearestTriangle(graph, point) { let nearest = null; let distance = Number.POSITIVE_INFINITY; graph.triangles.forEach((triangle, index) => { const next = Math.hypot(triangle.centroid[0] - point[0], triangle.centroid[1] - point[1], triangle.centroid[2] - point[2]); if (next < distance) { nearest = index; distance = next; } }); return { id: nearest, distance }; } function closestComponentGap(graph, leftComponent, rightComponent) { const left = graph.triangles.filter((triangle) => triangle.component === leftComponent); const right = graph.triangles.filter((triangle) => triangle.component === rightComponent) .sort((a, b) => a.centroid[0] - b.centroid[0]); let best = null; let bestDistance = Number.POSITIVE_INFINITY; const insertion = (value) => { let low = 0; let high = right.length; while (low < high) { const middle = (low + high) >> 1; if (right[middle].centroid[0] < value) low = middle + 1; else high = middle; } return low; }; for (const candidate of left) { const center = insertion(candidate.centroid[0]); for (const direction of [-1, 1]) { for (let index = center + (direction < 0 ? -1 : 0); index >= 0 && index < right.length; index += direction) { const other = right[index]; if (Math.abs(other.centroid[0] - candidate.centroid[0]) >= bestDistance) break; const distance = Math.hypot( other.centroid[0] - candidate.centroid[0], other.centroid[1] - candidate.centroid[1], other.centroid[2] - candidate.centroid[2], ); if (distance < bestDistance) { bestDistance = distance; best = { start: candidate.centroid, end: other.centroid, distance }; } } } } return best; } function validateNavigation(document, fixture, blockers, warnings, suggestions) { const graph = navigationComponents(document); if (!graph?.triangles.length) { blockers.push("Navigation GLB has no indexed triangle surface."); return; } if (graph.componentCount > 1) warnings.push(`Navigation mesh contains ${graph.componentCount} connected components.`); const parents = Array.from({ length: graph.componentCount }, (_, index) => index); const root = (component) => { while (parents[component] !== component) { parents[component] = parents[parents[component]]; component = parents[component]; } return component; }; const unite = (left, right) => { parents[root(right)] = root(left); }; for (const link of fixture.offMeshLinks ?? []) { const start = nearestTriangle(graph, link.start); const end = nearestTriangle(graph, link.end); if (start.distance > 5 || end.distance > 5) { blockers.push(`Off-mesh link ${link.id ?? ""} does not snap to both navigation surfaces.`); continue; } unite(graph.triangles[start.id].component, graph.triangles[end.id].component); } let previous = fixture.entrance; for (const objective of fixture.objectives.filter((candidate) => !candidate.optional)) { const start = nearestTriangle(graph, previous); const end = nearestTriangle(graph, objective.position); if (start.distance > 5) blockers.push(`Navigation origin before ${objective.name} is ${start.distance.toFixed(2)}m from the mesh.`); if (end.distance > 5) blockers.push(`${objective.name} is ${end.distance.toFixed(2)}m from the navigation mesh.`); if (start.id !== null && end.id !== null && root(graph.triangles[start.id].component) !== root(graph.triangles[end.id].component)) { blockers.push(`No navigation route connects the previous objective to ${objective.name}.`); const suggestion = closestComponentGap( graph, graph.triangles[start.id].component, graph.triangles[end.id].component, ); if (suggestion) suggestions.push({ id: `review-${objective.id}`, start: suggestion.start, end: suggestion.end, bidirectional: true, gapDistance: Number(suggestion.distance.toFixed(3)), authoredReason: `Review the closest gap between the route before ${objective.name} and its navigation surface.`, }); } previous = objective.position; } } export async function validateDungeonPack(slug) { const recipe = await loadRecipe(slug); const fixture = await fixtureFor(slug); const output = path.join(workRoot, slug); const packFile = path.join(output, "dungeon-pack.manifest.json"); const blockers = [...(fixture.blockers ?? [])]; const warnings = []; const offMeshLinkSuggestions = []; if (!await exists(packFile)) blockers.push("Run dungeon:build before validation."); const manifest = await exists(packFile) ? await readJson(packFile) : null; if (manifest && /[A-Za-z]:[\\/]/.test(JSON.stringify(manifest))) blockers.push("Pack manifest contains an absolute Windows path."); const documents = new Map(); for (const asset of fixture.assets) { const file = path.resolve(projectRoot, asset.path); if (!await exists(file)) { blockers.push(`Missing ${asset.path}.`); continue; } const validated = await validateGltf(file, asset.role, blockers); if (validated) documents.set(asset.role, validated.document); const recorded = manifest?.assets?.find((candidate) => candidate.path === asset.path); if (!recorded || recorded.checksum !== await sha256(file)) blockers.push(`${asset.path} checksum does not match the current pack manifest.`); } for (const creature of fixture.creatures) { const file = path.resolve(projectRoot, creature.path); if (!await exists(file)) { blockers.push(`Missing ${creature.path}.`); continue; } const validated = await validateGltf(file, "creature", blockers); if (validated) validateCreature(validated.document, creature, blockers); } const entityIds = new Set(fixture.entities); for (const spawn of fixture.spawns) if (!entityIds.has(spawn.entityId)) blockers.push(`${spawn.id} references missing entity ${spawn.entityId}.`); for (const variant of recipe.difficultyVariants) if (!Number.isInteger(variant.spawnMask) || variant.spawnMask <= 0) blockers.push(`${variant.id} has an invalid spawn mask.`); if (!fixture.entrance.every(Number.isFinite)) blockers.push("Entrance contains non-finite coordinates."); const navigation = documents.get("navigation"); if (navigation) validateNavigation(navigation, fixture, blockers, warnings, offMeshLinkSuggestions); else blockers.push("Navigation asset was not validated."); const report = { schemaVersion: 1, dungeonId: slug, status: blockers.length ? "blocked" : "green", blockers: [...new Set(blockers)].sort(), warnings: [...new Set(warnings)].sort(), checks: { deterministicManifest: Boolean(manifest), gltfAssets: fixture.assets.length, creatures: fixture.creatures.length, spawns: fixture.spawns.length, mandatoryObjectives: fixture.objectives.filter((objective) => !objective.optional).length, }, review: { offMeshLinkSuggestions }, }; await writeJson(path.join(output, "validation-report.json"), report); return report; } export async function enableDungeon(slug) { await loadRecipe(slug); const reportFile = path.join(workRoot, slug, "validation-report.json"); if (!await exists(reportFile)) throw new Error(`${slug}: no validation report; run dungeon:validate first.`); const report = await readJson(reportFile); if (report.status !== "green" || report.blockers?.length) throw new Error(`${slug}: validation is blocked and cannot be enabled.`); const availabilityFile = path.join(projectRoot, "src", "game", "generated", "dungeonAvailability.json"); const availability = await readJson(availabilityFile); availability.enabled[slug] = true; await writeJson(availabilityFile, availability); return { status: "enabled", dungeonId: slug, file: relativeProjectPath(availabilityFile) }; }