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

940 lines
41 KiB
JavaScript

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() || "<unnamed>"}.`);
}
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 ?? "<unnamed>"} 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) };
}