940 lines
41 KiB
JavaScript
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) };
|
|
}
|