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));