import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const pipelineDirectory = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); export const projectRoot = path.resolve(pipelineDirectory, "../.."); export function optionValue(argv, name, fallback = null) { const equals = argv.find((value) => value.startsWith(name + "=")); if (equals) return equals.slice(name.length + 1); const index = argv.indexOf(name); if (index >= 0 && argv[index + 1] && !argv[index + 1].startsWith("--")) { return argv[index + 1]; } return fallback; } export function hasFlag(argv, name) { return argv.includes(name); } export function resolveProject(value) { return path.resolve(projectRoot, value); } export function projectPath(value) { return path.relative(projectRoot, value).split(path.sep).join("/"); } export async function readJson(file) { return JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, "")); } export async function exists(file) { try { await stat(file); return true; } catch { return false; } } export function slugify(value) { return String(value ?? "") .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-zA-Z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); } export function camelCase(value) { const words = slugify(value).split("-").filter(Boolean); return words.map((word, index) => ( index === 0 ? word : word[0].toUpperCase() + word.slice(1) )).join(""); } export function constantCase(value) { return slugify(value).replaceAll("-", "_").toUpperCase(); } export function normalizedResourcePath(value) { return String(value ?? "") .replaceAll("/", "\\") .replace(/^\\+/, "") .toLowerCase(); } export function sourceRootFor(recipe) { return path.resolve( process.env[recipe.source.rootEnvironmentVariable] ?? path.join(projectRoot, recipe.source.relativeDefault), ); } export function recipeArgument(argv, fallbackFile) { const explicit = optionValue(argv, "--recipe"); return explicit ? path.resolve(projectRoot, explicit) : path.resolve(fallbackFile); } function assertObject(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(label + " must be an object."); } } function assertString(value, label) { if (typeof value !== "string" || !value.trim()) { throw new Error(label + " must be a non-empty string."); } } export function validateEnvironmentRecipe(recipe, file = "environment recipe") { assertObject(recipe, file); assertString(recipe.slug, file + ".slug"); assertString(recipe.title, file + ".title"); assertObject(recipe.source, file + ".source"); for (const key of [ "rootEnvironmentVariable", "relativeDefault", "resourceRoot", "wdb", "model", "dungeonConfig", "dungeonConfigId", ]) { assertString(recipe.source[key], file + ".source." + key); } assertObject(recipe.coordinateSystem, file + ".coordinateSystem"); if (!(Number(recipe.coordinateSystem.metersPerSourceUnit) > 0)) { throw new Error(file + ".coordinateSystem.metersPerSourceUnit must be positive."); } assertObject(recipe.navigation, file + ".navigation"); return recipe; } export function validatePopulationRecipe(recipe, file = "population recipe") { assertObject(recipe, file); assertString(recipe.dungeonId, file + ".dungeonId"); if (!Number.isInteger(recipe.zoneId) || recipe.zoneId <= 0) { throw new Error(file + ".zoneId must be a positive integer."); } assertObject(recipe.files, file + ".files"); for (const key of [ "snapshot", "generatedSource", "report", "environmentMetadata", "navigation", "actorManifest", "actorOutput", ]) { assertString(recipe.files[key], file + ".files." + key); } assertObject(recipe.source, file + ".source"); if (!Array.isArray(recipe.actors) || !Array.isArray(recipe.templates)) { throw new Error(file + " must contain actors and templates arrays."); } const actorIds = new Set(); for (const actor of recipe.actors) { assertString(actor.id, file + " actor id"); assertString(actor.sourceModel, file + " actor sourceModel"); if (actorIds.has(actor.id)) throw new Error("Duplicate actor id " + actor.id + "."); actorIds.add(actor.id); } const templateIds = new Set(); const templatesById = new Map(); const classifications = new Set(["combat", "boss", "deferred-object", "unclassified"]); for (const template of recipe.templates) { if (!Number.isInteger(template.id)) throw new Error("Template ids must be integers."); if (templateIds.has(template.id)) throw new Error("Duplicate template id " + template.id + "."); templateIds.add(template.id); if (!classifications.has(template.classification)) { throw new Error("Unsupported classification " + template.classification + "."); } if (["combat", "boss"].includes(template.classification) && !actorIds.has(template.actor) && template.proceduralFallback !== true) { throw new Error("Combat template " + template.id + " has no mapped actor or explicit procedural fallback."); } if (template.source?.scriptRoots !== undefined && (!Array.isArray(template.source.scriptRoots) || template.source.scriptRoots.some((root) => typeof root !== "string" || !root.trim()))) { throw new Error("Template " + template.id + " source.scriptRoots must contain non-empty strings."); } templatesById.set(template.id, template); } const scriptedIds = new Set(); const validateScriptedBase = (entry, label) => { assertObject(entry, label); assertString(entry.id, label + ".id"); if (scriptedIds.has(entry.id)) throw new Error("Duplicate scripted placement id " + entry.id + "."); scriptedIds.add(entry.id); if (!Number.isInteger(entry.sourceAnchorSpawnId) || entry.sourceAnchorSpawnId <= 0) { throw new Error(label + ".sourceAnchorSpawnId must be a positive integer."); } if (entry.controllerSpawnId !== undefined && (!Number.isInteger(entry.controllerSpawnId) || entry.controllerSpawnId <= 0)) { throw new Error(label + ".controllerSpawnId must be a positive integer."); } if (!Array.isArray(entry.evidence) || !entry.evidence.length || entry.evidence.some((value) => typeof value !== "string" || !value.trim())) { throw new Error(label + ".evidence must contain non-empty source references."); } }; for (const [index, spawn] of (recipe.scriptedSpawns ?? []).entries()) { const label = file + ".scriptedSpawns[" + index + "]"; validateScriptedBase(spawn, label); const template = templatesById.get(spawn.templateId); if (!template || !["combat", "boss"].includes(template.classification)) { throw new Error(label + ".templateId must reference a combat or boss template."); } if (spawn.sourceOffset !== undefined && (!Array.isArray(spawn.sourceOffset) || spawn.sourceOffset.length !== 3 || spawn.sourceOffset.some((value) => !Number.isFinite(value)))) { throw new Error(label + ".sourceOffset must contain three finite source-unit values."); } if (spawn.direction !== undefined && !Number.isFinite(spawn.direction)) { throw new Error(label + ".direction must be finite."); } if (spawn.objective !== undefined && typeof spawn.objective !== "boolean") { throw new Error(label + ".objective must be boolean when provided."); } } for (const [index, formation] of (recipe.scriptedFormations ?? []).entries()) { const label = file + ".scriptedFormations[" + index + "]"; validateScriptedBase(formation, label); assertString(formation.sourceScriptRoot, label + ".sourceScriptRoot"); if (!(Number(formation.spacingSourceUnits) > 0)) { throw new Error(label + ".spacingSourceUnits must be positive."); } if (!Number.isFinite(formation.direction)) { throw new Error(label + ".direction must be finite."); } if (!Array.isArray(formation.members) || !formation.members.length) { throw new Error(label + ".members must be a non-empty array."); } for (const [memberIndex, member] of formation.members.entries()) { const memberLabel = label + ".members[" + memberIndex + "]"; assertObject(member, memberLabel); const template = templatesById.get(member.templateId); if (!template || !["combat", "boss"].includes(template.classification)) { throw new Error(memberLabel + ".templateId must reference a combat or boss template."); } if (!Number.isInteger(member.row) || member.row < 1 || !Number.isInteger(member.column)) { throw new Error(memberLabel + " requires a positive integer row and integer column."); } } } return recipe; } export async function loadEnvironmentRecipe(argv, fallbackFile) { const file = recipeArgument(argv, fallbackFile); return { file, recipe: validateEnvironmentRecipe(await readJson(file), projectPath(file)) }; } export async function loadPopulationRecipe(argv, fallbackFile) { const file = recipeArgument(argv, fallbackFile); return { file, recipe: validatePopulationRecipe(await readJson(file), projectPath(file)) }; } export function publicUrlFor(projectFile) { const publicRoot = path.join(projectRoot, "public"); const relative = path.relative(publicRoot, projectFile); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Runtime creature assets must be written beneath public/: " + projectFile); } return "/" + relative.split(path.sep).join("/"); }