Files
healer-man/scripts/runewaker-pipeline/extract-mechanics.mjs
T
2026-08-14 15:56:39 -04:00

245 lines
9.5 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
camelCase,
constantCase,
exists,
pipelineDirectory,
projectPath,
projectRoot,
recipeArgument,
readJson,
resolveProject,
sourceRootFor,
validatePopulationRecipe,
} from "./lib/recipe.mjs";
const argv = process.argv.slice(2);
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey-population.json");
const recipeFile = recipeArgument(argv, fallbackRecipe);
const recipe = validatePopulationRecipe(await readJson(recipeFile), projectPath(recipeFile));
const sourceRoot = sourceRootFor(recipe);
const snapshotFile = resolveProject(recipe.files.snapshot);
if (!await exists(snapshotFile)) {
throw new Error("Portable population snapshot is required before mechanics extraction: " + snapshotFile);
}
const environmentRecipeFile = recipe.files.environmentRecipe
? resolveProject(recipe.files.environmentRecipe)
: path.join(pipelineDirectory, "recipes", recipe.dungeonId + ".json");
const environmentRecipe = await exists(environmentRecipeFile)
? await readJson(environmentRecipeFile)
: null;
const dungeonConfigRelative = recipe.source.dungeonConfig
?? environmentRecipe?.source?.dungeonConfig
?? "Tools/dungeon_config.json";
const dungeonConfigId = recipe.source.dungeonConfigId
?? recipe.sourceDungeonConfigId
?? environmentRecipe?.source?.dungeonConfigId;
if (!dungeonConfigId) throw new Error("Population recipe does not identify its dungeon_config entry.");
const configFile = path.join(sourceRoot, dungeonConfigRelative);
const config = await readJson(configFile);
const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonConfigId);
if (!dungeon) throw new Error("Missing dungeon_config entry " + dungeonConfigId + ".");
const snapshot = await readJson(snapshotFile);
const SIGNALS = [
{ kind: "summon", pattern: /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i },
{ kind: "door", pattern: /door|gate|seal|barrier|open.*object|close.*object/i },
{ kind: "timer", pattern: /timer|delay|sleep|wait|schedule/i },
{ kind: "movement", pattern: /moveto|move_to|patrol|waypoint|autoplot|path/i },
{ kind: "spell", pattern: /cast|magic|spell|skill/i },
{ kind: "death", pattern: /ondead|on_dead|dead|death|onkill|on_kill/i },
{ kind: "phase", pattern: /phase|stage|state|wave/i },
{ kind: "interaction", pattern: /click|useobject|talk|speak|interact|trigger/i },
{ kind: "loot", pattern: /loot|treasure|drop|reward|chest/i },
];
function sha256Text(value) {
return createHash("sha256").update(value).digest("hex");
}
function clean(value) {
return String(value ?? "").replaceAll(String.fromCharCode(0), "").trim();
}
function sourceSignals(source, fileName) {
const signals = [];
const lines = source.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const text = lines[index].trim();
if (!text || text.startsWith("--")) continue;
const kinds = SIGNALS.filter((signal) => signal.pattern.test(text)).map((signal) => signal.kind);
if (!kinds.length) continue;
signals.push({ file: fileName, line: index + 1, kinds, text: text.slice(0, 500) });
}
return signals;
}
function functionNames(source) {
return [...source.matchAll(/\bfunction\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)]
.map((match) => match[1]);
}
function referencedIds(source) {
return [...new Set([...source.matchAll(/\b(?:1\d{5}|7\d{5})\b/g)].map((match) => Number(match[0])))]
.sort((left, right) => left - right);
}
const luaRoot = path.join(sourceRoot, recipe.source.luaRoot ?? "Resource/luascript");
const luaSources = [];
const luaSignals = [];
for (const fileName of dungeon.lua_files ?? []) {
const file = path.join(luaRoot, fileName);
if (!await exists(file)) {
luaSources.push({ file: fileName, exists: false, sha256: null, functions: [], referencedIds: [] });
continue;
}
const source = await readFile(file, "utf8");
const signals = sourceSignals(source, fileName);
luaSignals.push(...signals);
luaSources.push({
file: fileName,
exists: true,
sha256: sha256Text(source),
functions: functionNames(source),
referencedIds: referencedIds(source),
signalCounts: Object.fromEntries(SIGNALS.map(({ kind }) => [
kind,
signals.filter((signal) => signal.kinds.includes(kind)).length,
])),
});
}
const templateMetadata = new Map();
for (const row of snapshot.rows ?? []) {
const metadata = templateMetadata.get(row.templateId) ?? {
templateId: row.templateId,
name: row.name,
classification: row.classification,
spawnCount: 0,
autoPlots: new Set(),
plotClassNames: new Set(),
templateScripts: new Map(),
spells: new Map(),
};
metadata.spawnCount += 1;
if (clean(row.source?.autoPlot)) metadata.autoPlots.add(clean(row.source.autoPlot));
if (clean(row.source?.plotClassName)) metadata.plotClassNames.add(clean(row.source.plotClassName));
for (const [event, script] of Object.entries(row.source?.templateScripts ?? {})) {
if (clean(script)) metadata.templateScripts.set(event + ":" + clean(script), { event, script: clean(script) });
}
for (const spell of row.source?.spells ?? []) {
if (Number(spell.id) > 0) metadata.spells.set(Number(spell.id), {
id: Number(spell.id),
level: Number(spell.level ?? 0),
});
}
templateMetadata.set(row.templateId, metadata);
}
const templateCandidates = [...templateMetadata.values()]
.map((entry) => ({
templateId: entry.templateId,
name: entry.name,
classification: entry.classification,
spawnCount: entry.spawnCount,
autoPlots: [...entry.autoPlots].sort(),
plotClassNames: [...entry.plotClassNames].sort(),
templateScripts: [...entry.templateScripts.values()]
.sort((left, right) => left.event.localeCompare(right.event) || left.script.localeCompare(right.script)),
spells: [...entry.spells.values()].sort((left, right) => left.id - right.id),
}))
.sort((left, right) => left.templateId - right.templateId);
const bossRows = (snapshot.rows ?? [])
.filter((row) => row.classification === "boss")
.map((row) => ({
spawnId: row.spawnId,
templateId: row.templateId,
name: row.name,
sourcePosition: row.sourcePosition,
autoPlot: clean(row.source?.autoPlot) || null,
plotClassName: clean(row.source?.plotClassName) || null,
}));
const categories = Object.fromEntries(SIGNALS.map(({ kind }) => [
kind,
luaSignals.filter((signal) => signal.kinds.includes(kind)).length
+ templateCandidates.filter((entry) => (
[...entry.autoPlots, ...entry.plotClassNames, ...entry.templateScripts.map((item) => item.script)]
.some((value) => SIGNALS.find((signal) => signal.kind === kind).pattern.test(value))
)).length,
]));
const report = {
schemaVersion: 1,
dungeonId: recipe.dungeonId,
zoneId: recipe.zoneId,
status: "review-required",
source: {
populationRecipe: projectPath(recipeFile),
snapshot: projectPath(snapshotFile),
snapshotContentSha256: snapshot.contentSha256,
dungeonConfig: projectPath(configFile),
dungeonConfigId,
},
configuredBossTemplateIds: dungeon.boss_guids ?? [],
configuredBossPositions: dungeon.boss_positions ?? {},
bossRows,
luaSources,
luaSignals,
templateCandidates,
categories,
reviewQueue: [
"Map authoritative spell ids/scripts to HealerMan EnemyAttackDefinition data.",
"Implement and test doors/gates as encounter state, not static combat entities.",
"Map summons and waves only after identifying their trigger, lifetime, and reset rules.",
"Convert patrol/AutoPlot routes to navmesh-projected paths.",
"Separate interactables/loot from combat population and define offline state transitions.",
"Add encounter-specific tests and a browser playtest before marking mechanics green.",
],
safety: {
executableMechanicsGenerated: false,
reason: "Lua/AutoPlot syntax is audited as evidence; ambiguous behavior is never silently guessed.",
runtimeSqlDependency: false,
},
};
const reportFile = resolveProject(
recipe.files.mechanicsReport
?? "src/assets/game/dungeons/" + recipe.dungeonId + "/mechanics-audit.json",
);
const camel = camelCase(recipe.dungeonId);
const generatedFile = resolveProject(
recipe.files.mechanicsSource
?? "src/game/generated/" + camel + "Mechanics.generated.ts",
);
const prefix = constantCase(recipe.dungeonId);
const generated = [
"/* Generated evidence only. Review before mapping candidates to executable encounter logic. */",
"export const " + prefix + "_MECHANICS_AUDIT = " + JSON.stringify({
schemaVersion: report.schemaVersion,
dungeonId: report.dungeonId,
status: report.status,
categories: report.categories,
bossRows: report.bossRows,
templateCandidates: report.templateCandidates,
reviewQueue: report.reviewQueue,
sourceReport: projectPath(reportFile),
}, null, 2) + " as const;",
"",
"export const " + prefix + "_MECHANICS_REVIEW_REQUIRED = true as const;",
"",
].join("\n");
await mkdir(path.dirname(reportFile), { recursive: true });
await mkdir(path.dirname(generatedFile), { recursive: true });
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
await writeFile(generatedFile, generated, "utf8");
console.log("[runewaker-mechanics] audited " + luaSources.length + " Lua files, "
+ templateCandidates.length + " templates, and " + bossRows.length + " boss rows.");
console.log("[runewaker-mechanics] report: " + projectPath(reportFile));
console.log("[runewaker-mechanics] evidence source: " + projectPath(generatedFile));