245 lines
9.5 KiB
JavaScript
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));
|