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

186 lines
5.9 KiB
JavaScript

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 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.");
}
}
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("/");
}