255 lines
9.5 KiB
JavaScript
255 lines
9.5 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 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("/");
|
|
}
|