703 lines
28 KiB
JavaScript
703 lines
28 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
|
const reportJsonFile = path.join(
|
|
projectRoot,
|
|
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
|
|
);
|
|
const reportMarkdownFile = path.join(
|
|
projectRoot,
|
|
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
|
|
);
|
|
|
|
export const IMPLEMENTATION_STATUSES = Object.freeze([
|
|
"implemented",
|
|
"verified",
|
|
"deferred-source-gap",
|
|
"deferred-asset-gap",
|
|
"deferred-runtime-gap",
|
|
"excluded-non-gameplay",
|
|
"intentional-empty",
|
|
]);
|
|
export const FIDELITY_LEVELS = Object.freeze([
|
|
"source-exact",
|
|
"source-backed-adapted",
|
|
"healer-man-authored",
|
|
]);
|
|
|
|
const EXPECTED_FALLBACK_BOSSES = Object.freeze([
|
|
"rw-fa-100070",
|
|
"rw-fa-100561",
|
|
"rw-fa-100562",
|
|
"rw-fa-100563",
|
|
]);
|
|
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
|
|
"aeternal-circle",
|
|
"dungeon-of-dalanis",
|
|
"grafu-castle",
|
|
"kal-turok-hive",
|
|
"lair-demon-dragon",
|
|
"tomb-seven-heroes",
|
|
"warnorken-arena",
|
|
"zurhidon-stronghold",
|
|
]);
|
|
const EXPECTED_INTENTIONAL_EMPTY_PACKAGES = Object.freeze([
|
|
"demon-stronghold-125",
|
|
"zurhidon-stronghold-124",
|
|
]);
|
|
|
|
const SIGNAL_PATTERNS = Object.freeze([
|
|
["summon", /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i],
|
|
["door", /door|gate|seal|barrier|open.*object|close.*object/i],
|
|
["timer", /timer|delay|sleep|wait|schedule/i],
|
|
["movement", /moveto|move_to|patrol|waypoint|autoplot|path/i],
|
|
["spell", /cast|magic|spell|skill/i],
|
|
["death", /ondead|on_dead|dead|death|onkill|on_kill/i],
|
|
["phase", /phase|stage|state|wave/i],
|
|
["interaction", /click|useobject|talk|speak|interact|trigger/i],
|
|
["loot", /loot|treasure|drop|reward|chest/i],
|
|
]);
|
|
|
|
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
|
|
const projectPath = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
|
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
const sha256File = async (file) => sha256(await readFile(file));
|
|
const compareText = (left, right) => String(left).localeCompare(String(right));
|
|
const sameMembers = (left, right) => (
|
|
left.length === right.length && left.every((value, index) => value === right[index])
|
|
);
|
|
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
|
|
|
|
function generatedJson(source, name, suffix) {
|
|
const marker = "export const " + name + " = ";
|
|
const start = source.indexOf(marker);
|
|
if (start < 0) throw new Error("Missing generated constant " + name + ".");
|
|
const valueStart = start + marker.length;
|
|
const valueEnd = source.indexOf(suffix, valueStart);
|
|
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
|
|
return JSON.parse(source.slice(valueStart, valueEnd));
|
|
}
|
|
|
|
function runtimeTemplateId(entityId) {
|
|
const match = String(entityId).match(/-(\d+)$/);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
function categoriesFor(value) {
|
|
return SIGNAL_PATTERNS
|
|
.filter(([, pattern]) => pattern.test(String(value ?? "")))
|
|
.map(([kind]) => kind);
|
|
}
|
|
|
|
function statusCounts(items) {
|
|
return Object.fromEntries(IMPLEMENTATION_STATUSES.map((status) => [
|
|
status,
|
|
items.filter((item) => item.status === status).length,
|
|
]));
|
|
}
|
|
|
|
function fidelityCounts(items) {
|
|
return Object.fromEntries(FIDELITY_LEVELS.map((fidelity) => [
|
|
fidelity,
|
|
items.filter((item) => item.fidelity === fidelity).length,
|
|
]));
|
|
}
|
|
|
|
function sourceFile(pathName, fileSha256, contentSha256 = null) {
|
|
return {
|
|
path: pathName,
|
|
sha256: fileSha256,
|
|
...(contentSha256 ? { contentSha256 } : {}),
|
|
};
|
|
}
|
|
|
|
function deferredRowRecord({ dungeonId, zoneId, row, snapshotPath, snapshotFileSha256, snapshot }) {
|
|
const evidenceText = [
|
|
row.name,
|
|
row.sourceName,
|
|
row.modelPath,
|
|
row.source?.roleName,
|
|
row.source?.autoPlot,
|
|
row.source?.plotClassName,
|
|
...Object.values(row.source?.templateScripts ?? {}),
|
|
].filter(Boolean).join(" ");
|
|
return {
|
|
id: "row:" + dungeonId + ":" + row.spawnId,
|
|
kind: "deferred-source-row",
|
|
dungeonId,
|
|
zoneId,
|
|
sourceIds: {
|
|
spawnId: row.spawnId,
|
|
templateId: row.templateId,
|
|
roomId: Number(row.source?.roomId ?? 0),
|
|
},
|
|
name: row.name,
|
|
sourceName: row.sourceName,
|
|
sourceClassification: row.classification,
|
|
sourcePosition: row.sourcePosition,
|
|
direction: row.direction,
|
|
modelPath: row.modelPath ?? null,
|
|
heuristicCategories: categoriesFor(evidenceText),
|
|
status: "deferred-source-gap",
|
|
fidelity: "source-exact",
|
|
source: {
|
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
|
},
|
|
blocker: "The source row preserves identity and placement, but no reviewed evidence proves its gameplay behavior.",
|
|
nextWork: "Identify the authoritative trigger, state transition, reset/persistence rules, and asset requirements; then add an explicit source-spawn mapping.",
|
|
clearingTest: "Add a deterministic fixture proving that source spawn " + row.spawnId
|
|
+ " executes the reviewed behavior and resets idempotently.",
|
|
};
|
|
}
|
|
|
|
function runtimeSpellMatches(entities, templateId, spellId) {
|
|
return Object.entries(entities).flatMap(([entityId, entity]) => {
|
|
if (runtimeTemplateId(entityId) !== templateId) return [];
|
|
return (entity.combat?.attacks ?? [])
|
|
.filter((attack) => Number(attack.spellId ?? attack.source?.spellId ?? 0) === spellId)
|
|
.map((attack) => ({
|
|
entityId,
|
|
attackId: attack.id,
|
|
telegraph: attack.telegraph ?? null,
|
|
delivery: attack.delivery,
|
|
}));
|
|
});
|
|
}
|
|
|
|
function baseEvidenceRecord({
|
|
id,
|
|
evidenceKind,
|
|
dungeonId,
|
|
zoneId,
|
|
templateId = null,
|
|
categories,
|
|
value,
|
|
source,
|
|
}) {
|
|
return {
|
|
id,
|
|
kind: "mechanics-evidence",
|
|
evidenceKind,
|
|
dungeonId,
|
|
zoneId,
|
|
sourceIds: templateId == null ? {} : { templateId },
|
|
categories: [...new Set(categories)].sort(compareText),
|
|
value,
|
|
status: "deferred-source-gap",
|
|
fidelity: "source-exact",
|
|
source,
|
|
blocker: "This is source evidence, but it does not prove a complete executable mechanic by itself.",
|
|
nextWork: "Review the surrounding source to establish trigger, target, timing, state effects, reset, persistence, and multiplayer behavior.",
|
|
clearingTest: "Add a focused encounter fixture tied to this stable evidence ID and verify its full state transition and reset behavior.",
|
|
};
|
|
}
|
|
|
|
function templateEvidenceRecords({
|
|
dungeonId,
|
|
zoneId,
|
|
mechanics,
|
|
mechanicsPath,
|
|
mechanicsFileSha256,
|
|
snapshot,
|
|
snapshotPath,
|
|
snapshotFileSha256,
|
|
entities,
|
|
}) {
|
|
const records = [];
|
|
const commonSource = {
|
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
|
};
|
|
for (const candidate of mechanics.templateCandidates ?? []) {
|
|
const templateId = Number(candidate.templateId);
|
|
for (const value of candidate.autoPlots ?? []) {
|
|
records.push(baseEvidenceRecord({
|
|
id: "signal:" + dungeonId + ":template:" + templateId + ":autoplot:" + sha256(value).slice(0, 12),
|
|
evidenceKind: "auto-plot",
|
|
dungeonId,
|
|
zoneId,
|
|
templateId,
|
|
categories: categoriesFor(value),
|
|
value,
|
|
source: commonSource,
|
|
}));
|
|
}
|
|
for (const value of candidate.plotClassNames ?? []) {
|
|
records.push(baseEvidenceRecord({
|
|
id: "signal:" + dungeonId + ":template:" + templateId + ":plot-class:" + sha256(value).slice(0, 12),
|
|
evidenceKind: "plot-class",
|
|
dungeonId,
|
|
zoneId,
|
|
templateId,
|
|
categories: categoriesFor(value),
|
|
value,
|
|
source: commonSource,
|
|
}));
|
|
}
|
|
for (const script of candidate.templateScripts ?? []) {
|
|
const value = script.event + ":" + script.script;
|
|
records.push(baseEvidenceRecord({
|
|
id: "signal:" + dungeonId + ":template:" + templateId + ":script:"
|
|
+ script.event + ":" + sha256(script.script).slice(0, 12),
|
|
evidenceKind: "template-script",
|
|
dungeonId,
|
|
zoneId,
|
|
templateId,
|
|
categories: categoriesFor(value),
|
|
value: { event: script.event, script: script.script },
|
|
source: commonSource,
|
|
}));
|
|
}
|
|
for (const spell of candidate.spells ?? []) {
|
|
const spellId = Number(spell.id);
|
|
const matches = runtimeSpellMatches(entities, templateId, spellId);
|
|
const record = baseEvidenceRecord({
|
|
id: "signal:" + dungeonId + ":template:" + templateId + ":spell:" + spellId,
|
|
evidenceKind: "source-spell",
|
|
dungeonId,
|
|
zoneId,
|
|
templateId,
|
|
categories: ["spell"],
|
|
value: { spellId, level: Number(spell.level ?? 0) },
|
|
source: commonSource,
|
|
});
|
|
if (matches.length) {
|
|
record.status = "verified";
|
|
record.fidelity = "source-backed-adapted";
|
|
record.runtimeMappings = matches;
|
|
record.blocker = "The original RuneWaker coefficient and native stat inputs are not available in the portable package.";
|
|
record.nextWork = "Retain the exact spell identity and revisit numerical fidelity only if authoritative formula inputs are recovered.";
|
|
record.clearingTest = "npm run runewaker:audit:gameplay verifies the source spell ID and runtime attack remain aligned.";
|
|
}
|
|
records.push(record);
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function luaEvidenceRecords({ dungeonId, zoneId, mechanics, mechanicsPath, mechanicsFileSha256 }) {
|
|
const luaHashByFile = new Map(
|
|
(mechanics.luaSources ?? []).map((source) => [source.file, source.sha256]),
|
|
);
|
|
return (mechanics.luaSignals ?? []).map((signal) => baseEvidenceRecord({
|
|
id: "signal:" + dungeonId + ":lua:" + signal.file + ":" + signal.line,
|
|
evidenceKind: "lua-line",
|
|
dungeonId,
|
|
zoneId,
|
|
categories: signal.kinds ?? [],
|
|
value: { line: signal.line, text: signal.text },
|
|
source: {
|
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
|
lua: sourceFile(signal.file, luaHashByFile.get(signal.file) ?? null),
|
|
},
|
|
}));
|
|
}
|
|
|
|
function renderMarkdown(report) {
|
|
const rows = report.dungeons.map((dungeon) => [
|
|
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.deferredSourceRows.length, "|",
|
|
dungeon.mechanicsEvidence.length, "|", dungeon.evidenceStatusCounts.verified, "|",
|
|
dungeon.evidenceStatusCounts["deferred-source-gap"], "|", dungeon.bossObjectives, "|",
|
|
dungeon.packageAssessment.status, "|",
|
|
].join(" "));
|
|
const categoryRows = Object.entries(report.summary.evidenceCategoryMemberships)
|
|
.map(([kind, count]) => "| " + kind + " | " + count + " |");
|
|
const blockerRows = Object.entries(report.blockerGroups)
|
|
.map(([key, value]) => "| " + key + " | " + value.items + " | " + value.nextWork + " |");
|
|
return [
|
|
"# RuneWaker encounter implementation and fidelity ledger",
|
|
"",
|
|
"Structural validation: **" + report.validationStatus + "**",
|
|
"Content completion: **" + report.completionStatus + "**",
|
|
"",
|
|
"This report is deterministic and offline. Its JSON companion inventories every deferred",
|
|
"source spawn row and every direct mechanics evidence record. A regex category membership",
|
|
"is a review label, not a unique mechanic; one evidence record may belong to several categories.",
|
|
"",
|
|
"## Coverage",
|
|
"",
|
|
"- " + report.summary.dungeons + " dungeon packages.",
|
|
"- " + report.summary.deferredSourceRows + " deferred source rows with stable spawn IDs.",
|
|
"- " + report.summary.uniqueMechanicsEvidence + " unique mechanics evidence records: "
|
|
+ report.summary.luaEvidenceSignals + " Lua lines and "
|
|
+ report.summary.templateEvidenceSignals + " template references.",
|
|
"- " + report.summary.evidenceCategoryMembershipCount
|
|
+ " overlapping category memberships; this number is intentionally not reported as mechanics.",
|
|
"- " + report.summary.verifiedEvidence + " evidence records have an exact executable source-spell mapping.",
|
|
"- " + report.summary.sourceGapEvidence + " evidence records remain source-review gaps.",
|
|
"",
|
|
"## Status definitions",
|
|
"",
|
|
"- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.",
|
|
"- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.",
|
|
"- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.",
|
|
"- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.",
|
|
"- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.",
|
|
"- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.",
|
|
"- `intentional-empty`: the authoritative packaged snapshot contains no population rows.",
|
|
"",
|
|
"## Per-dungeon inventory",
|
|
"",
|
|
"| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |",
|
|
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
|
...rows,
|
|
"",
|
|
"## Four explicit fallback bosses",
|
|
"",
|
|
"| Boss | Runtime ID | Status | Fidelity | Blocker |",
|
|
"| --- | --- | --- | --- | --- |",
|
|
...report.specialCases.fallbackBosses.map((boss) => "| " + boss.name + " | `" + boss.entityId
|
|
+ "` | " + boss.status + " | " + boss.fidelity + " | " + boss.blocker + " |"),
|
|
"",
|
|
"These attacks are intentionally playable and telegraphed HealerMan behavior. They are not",
|
|
"presented as original RuneWaker mechanics until authoritative source behavior is recovered.",
|
|
"",
|
|
"## Non-empty packages without confirmed boss objectives",
|
|
"",
|
|
...report.specialCases.nonEmptyZeroBossPackages.map((entry) => "- `" + entry.dungeonId + "`: "
|
|
+ entry.sourceRows + " rows; " + entry.blocker + " Next: " + entry.nextWork),
|
|
"",
|
|
"## Intentional empty variants",
|
|
"",
|
|
...report.specialCases.intentionalEmptyPackages.map((entry) => "- `" + entry.dungeonId + "` (Zone "
|
|
+ entry.zoneId + "): " + entry.blocker),
|
|
"",
|
|
"## Evidence category memberships",
|
|
"",
|
|
"| Category | Evidence records carrying the label |",
|
|
"| --- | ---: |",
|
|
...categoryRows,
|
|
"",
|
|
"## Remaining work ledger",
|
|
"",
|
|
"| Blocker group | Items | Clearing work |",
|
|
"| --- | ---: | --- |",
|
|
...blockerRows,
|
|
"",
|
|
"The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source",
|
|
"files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.",
|
|
"",
|
|
"## Validation issues",
|
|
"",
|
|
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function validateReport(report) {
|
|
const issues = [];
|
|
const inventory = report.dungeons.flatMap((dungeon) => [
|
|
...dungeon.deferredSourceRows,
|
|
...dungeon.mechanicsEvidence,
|
|
]);
|
|
const ids = new Set();
|
|
for (const item of inventory) {
|
|
if (ids.has(item.id)) issues.push("Duplicate inventory ID: " + item.id + ".");
|
|
ids.add(item.id);
|
|
if (!IMPLEMENTATION_STATUSES.includes(item.status)) {
|
|
issues.push(item.id + " has unsupported status " + item.status + ".");
|
|
}
|
|
if (!FIDELITY_LEVELS.includes(item.fidelity)) {
|
|
issues.push(item.id + " has unsupported fidelity " + item.fidelity + ".");
|
|
}
|
|
if (!item.blocker || !item.nextWork || !item.clearingTest) {
|
|
issues.push(item.id + " does not document blocker, next work, and clearing test.");
|
|
}
|
|
}
|
|
const fallbackIds = report.specialCases.fallbackBosses.map((boss) => boss.entityId).sort(compareText);
|
|
if (!sameMembers(fallbackIds, [...EXPECTED_FALLBACK_BOSSES])) {
|
|
issues.push("Expected exactly the four documented Forsaken Abbey fallback bosses; found "
|
|
+ fallbackIds.join(", ") + ".");
|
|
}
|
|
const zeroBossIds = report.specialCases.nonEmptyZeroBossPackages
|
|
.map((entry) => entry.dungeonId).sort(compareText);
|
|
if (!sameMembers(zeroBossIds, [...EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES])) {
|
|
issues.push("Non-empty zero-boss package set drifted: " + zeroBossIds.join(", ") + ".");
|
|
}
|
|
const emptyIds = report.specialCases.intentionalEmptyPackages
|
|
.map((entry) => entry.dungeonId).sort(compareText);
|
|
if (!sameMembers(emptyIds, [...EXPECTED_INTENTIONAL_EMPTY_PACKAGES])) {
|
|
issues.push("Intentional-empty package set drifted: " + emptyIds.join(", ") + ".");
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
export async function buildEncounterImplementationReport() {
|
|
const recipeNames = (await readdir(recipeDirectory))
|
|
.filter((name) => name.endsWith("-population.json"))
|
|
.sort(compareText);
|
|
const bossMechanicsPath = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
|
|
const [bossMechanics, bossMechanicsFileSha256] = await Promise.all([
|
|
readJson(bossMechanicsPath),
|
|
sha256File(bossMechanicsPath),
|
|
]);
|
|
const dungeons = [];
|
|
|
|
for (const recipeName of recipeNames) {
|
|
const recipeFile = path.join(recipeDirectory, recipeName);
|
|
const recipe = await readJson(recipeFile);
|
|
const dungeonId = recipe.dungeonId;
|
|
const zoneId = recipe.zoneId;
|
|
const snapshotFile = path.resolve(projectRoot, recipe.files.snapshot);
|
|
const mechanicsFile = path.resolve(
|
|
projectRoot,
|
|
recipe.files.mechanicsReport
|
|
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json",
|
|
);
|
|
const generatedSourceFile = path.resolve(projectRoot, recipe.files.generatedSource);
|
|
const [
|
|
recipeFileSha256,
|
|
snapshot,
|
|
snapshotFileSha256,
|
|
mechanics,
|
|
mechanicsFileSha256,
|
|
generatedSource,
|
|
generatedSourceSha256,
|
|
] = await Promise.all([
|
|
sha256File(recipeFile),
|
|
readJson(snapshotFile),
|
|
sha256File(snapshotFile),
|
|
readJson(mechanicsFile),
|
|
sha256File(mechanicsFile),
|
|
readFile(generatedSourceFile, "utf8"),
|
|
sha256File(generatedSourceFile),
|
|
]);
|
|
const prefix = constantCase(dungeonId);
|
|
const entities = generatedJson(
|
|
generatedSource,
|
|
prefix + "_ENTITIES",
|
|
" as const satisfies PopulationDefinitionMap;",
|
|
);
|
|
const bosses = generatedJson(
|
|
generatedSource,
|
|
prefix + "_BOSSES",
|
|
" as const satisfies readonly DungeonBossObjective[];",
|
|
);
|
|
const snapshotPath = projectPath(snapshotFile);
|
|
const mechanicsPath = projectPath(mechanicsFile);
|
|
const deferredSourceRows = (snapshot.rows ?? [])
|
|
.filter((row) => String(row.classification).startsWith("deferred"))
|
|
.map((row) => deferredRowRecord({
|
|
dungeonId,
|
|
zoneId,
|
|
row,
|
|
snapshotPath,
|
|
snapshotFileSha256,
|
|
snapshot,
|
|
}))
|
|
.sort((left, right) => left.sourceIds.spawnId - right.sourceIds.spawnId);
|
|
const mechanicsEvidence = [
|
|
...luaEvidenceRecords({
|
|
dungeonId,
|
|
zoneId,
|
|
mechanics,
|
|
mechanicsPath,
|
|
mechanicsFileSha256,
|
|
}),
|
|
...templateEvidenceRecords({
|
|
dungeonId,
|
|
zoneId,
|
|
mechanics,
|
|
mechanicsPath,
|
|
mechanicsFileSha256,
|
|
snapshot,
|
|
snapshotPath,
|
|
snapshotFileSha256,
|
|
entities,
|
|
}),
|
|
].sort((left, right) => compareText(left.id, right.id));
|
|
const packageStatus = snapshot.rows.length === 0
|
|
? "intentional-empty"
|
|
: bosses.length === 0
|
|
? "deferred-source-gap"
|
|
: "verified";
|
|
dungeons.push({
|
|
id: dungeonId,
|
|
zoneId,
|
|
sourceRows: snapshot.rows.length,
|
|
bossObjectives: bosses.length,
|
|
sourceArtifacts: {
|
|
recipe: sourceFile(projectPath(recipeFile), recipeFileSha256),
|
|
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
|
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
|
generatedRuntime: sourceFile(projectPath(generatedSourceFile), generatedSourceSha256),
|
|
upstreamHashes: snapshot.sourceHashes ?? {},
|
|
},
|
|
packageAssessment: {
|
|
status: packageStatus,
|
|
fidelity: "source-exact",
|
|
blocker: packageStatus === "intentional-empty"
|
|
? "The authoritative snapshot contains zero population rows; no combat population is fabricated."
|
|
: packageStatus === "deferred-source-gap"
|
|
? "The package is non-empty, but its preserved source classification confirms no boss objectives."
|
|
: "No package-level population blocker is recorded.",
|
|
nextWork: packageStatus === "deferred-source-gap"
|
|
? "Recover authoritative boss membership and encounter order before creating boss objectives."
|
|
: "Re-run the ledger whenever portable source content changes.",
|
|
clearingTest: packageStatus === "deferred-source-gap"
|
|
? "Add a source-backed boss-membership fixture and verify the generated objective list."
|
|
: "npm run runewaker:audit:gameplay",
|
|
},
|
|
deferredRowStatusCounts: statusCounts(deferredSourceRows),
|
|
evidenceStatusCounts: statusCounts(mechanicsEvidence),
|
|
evidenceFidelityCounts: fidelityCounts(mechanicsEvidence),
|
|
deferredSourceRows,
|
|
mechanicsEvidence,
|
|
});
|
|
}
|
|
|
|
dungeons.sort((left, right) => compareText(left.id, right.id));
|
|
const allRows = dungeons.flatMap((dungeon) => dungeon.deferredSourceRows);
|
|
const allEvidence = dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
|
|
const fallbackBosses = Object.entries(bossMechanics.bosses ?? {})
|
|
.filter(([, boss]) => boss.source?.kind === "healer-man-source-gap")
|
|
.map(([entityId, boss]) => {
|
|
const dungeon = dungeons.find((entry) => entry.id === boss.source.dungeonId);
|
|
return {
|
|
entityId,
|
|
name: boss.name,
|
|
dungeonId: boss.source.dungeonId,
|
|
templateId: Number(boss.source.templateId),
|
|
mechanicIds: (boss.mechanics ?? []).map((mechanic) => mechanic.id).sort(compareText),
|
|
telegraphedMechanicIds: (boss.mechanics ?? [])
|
|
.filter((mechanic) => mechanic.telegraph)
|
|
.map((mechanic) => mechanic.id)
|
|
.sort(compareText),
|
|
status: "implemented",
|
|
fidelity: "healer-man-authored",
|
|
source: {
|
|
bossMechanicsCatalog: sourceFile(projectPath(bossMechanicsPath), bossMechanicsFileSha256),
|
|
snapshot: dungeon?.sourceArtifacts.snapshot ?? null,
|
|
roots: boss.source.roots ?? [],
|
|
functions: boss.source.functions ?? [],
|
|
spellIds: boss.source.spellIds ?? [],
|
|
},
|
|
blocker: "Preserved source evidence does not expose an executable original combat kit.",
|
|
nextWork: "Replace the fallback only after authoritative spell timing, targeting, and effects are recovered; preserve modular IDs and telegraphs.",
|
|
clearingTest: "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker.",
|
|
};
|
|
})
|
|
.sort((left, right) => compareText(left.entityId, right.entityId));
|
|
const nonEmptyZeroBossPackages = dungeons
|
|
.filter((dungeon) => dungeon.sourceRows > 0 && dungeon.bossObjectives === 0)
|
|
.map((dungeon) => ({
|
|
dungeonId: dungeon.id,
|
|
zoneId: dungeon.zoneId,
|
|
sourceRows: dungeon.sourceRows,
|
|
status: "deferred-source-gap",
|
|
fidelity: "source-exact",
|
|
source: dungeon.sourceArtifacts.snapshot,
|
|
blocker: "The portable source package contains population rows but no source-confirmed boss membership.",
|
|
nextWork: "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.",
|
|
clearingTest: "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none.",
|
|
}));
|
|
const intentionalEmptyPackages = dungeons
|
|
.filter((dungeon) => dungeon.sourceRows === 0)
|
|
.map((dungeon) => ({
|
|
dungeonId: dungeon.id,
|
|
zoneId: dungeon.zoneId,
|
|
sourceRows: 0,
|
|
status: "intentional-empty",
|
|
fidelity: "source-exact",
|
|
source: dungeon.sourceArtifacts.snapshot,
|
|
blocker: "The authoritative packaged snapshot contains no active population rows.",
|
|
nextWork: "No population work is scheduled unless a different authoritative source snapshot is supplied.",
|
|
clearingTest: "Keep the zero-row snapshot hash and intentional-empty classification stable in this audit.",
|
|
}));
|
|
const categoryMemberships = {};
|
|
for (const evidence of allEvidence) {
|
|
for (const category of evidence.categories) {
|
|
categoryMemberships[category] = (categoryMemberships[category] ?? 0) + 1;
|
|
}
|
|
}
|
|
const luaEvidenceSignals = allEvidence.filter((item) => item.evidenceKind === "lua-line").length;
|
|
const templateEvidenceSignals = allEvidence.length - luaEvidenceSignals;
|
|
const report = {
|
|
schemaVersion: 1,
|
|
validationStatus: "green",
|
|
completionStatus: allRows.length || allEvidence.some((item) => item.status.startsWith("deferred"))
|
|
? "review-required"
|
|
: "complete",
|
|
scope: "All configured offline RuneWaker dungeon packages",
|
|
generatedBy: "scripts/runewaker-pipeline/audit-encounter-implementation.mjs",
|
|
deterministic: true,
|
|
policy: {
|
|
statuses: IMPLEMENTATION_STATUSES,
|
|
fidelities: FIDELITY_LEVELS,
|
|
evidenceRule: "Direct source rows and source references are inventoried once. Regex category memberships may overlap and are never counted as unique mechanics.",
|
|
failClosedRule: "When static evidence cannot prove complete behavior, the item remains deferred-source-gap rather than receiving a guessed implementation status.",
|
|
sourceExactRule: "source-exact describes preserved evidence identity and provenance, not an assertion that deferred gameplay behavior is known.",
|
|
},
|
|
summary: {
|
|
dungeons: dungeons.length,
|
|
deferredSourceRows: allRows.length,
|
|
uniqueMechanicsEvidence: allEvidence.length,
|
|
luaEvidenceSignals,
|
|
templateEvidenceSignals,
|
|
evidenceCategoryMembershipCount: Object.values(categoryMemberships)
|
|
.reduce((total, count) => total + count, 0),
|
|
evidenceCategoryMemberships: Object.fromEntries(
|
|
Object.entries(categoryMemberships).sort(([left], [right]) => compareText(left, right)),
|
|
),
|
|
deferredRowStatuses: statusCounts(allRows),
|
|
evidenceStatuses: statusCounts(allEvidence),
|
|
evidenceFidelities: fidelityCounts(allEvidence),
|
|
verifiedEvidence: allEvidence.filter((item) => item.status === "verified").length,
|
|
sourceGapEvidence: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
|
fallbackBosses: fallbackBosses.length,
|
|
nonEmptyZeroBossPackages: nonEmptyZeroBossPackages.length,
|
|
intentionalEmptyPackages: intentionalEmptyPackages.length,
|
|
},
|
|
blockerGroups: {
|
|
deferredSourceRows: {
|
|
items: allRows.length,
|
|
nextWork: "Review source semantics and add explicit source-spawn encounter mappings.",
|
|
},
|
|
unresolvedMechanicsEvidence: {
|
|
items: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
|
nextWork: "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior.",
|
|
},
|
|
adaptedSpellFormula: {
|
|
items: allEvidence.filter((item) => item.status === "verified"
|
|
&& item.fidelity === "source-backed-adapted").length,
|
|
nextWork: "Recover native formula inputs before claiming coefficient-level fidelity.",
|
|
},
|
|
sourceGapFallbackBosses: {
|
|
items: fallbackBosses.length,
|
|
nextWork: "Recover authoritative combat kits and replace only with source-backed modular mechanics.",
|
|
},
|
|
nonEmptyZeroBossPackages: {
|
|
items: nonEmptyZeroBossPackages.length,
|
|
nextWork: "Recover authoritative boss membership and objective order.",
|
|
},
|
|
},
|
|
specialCases: {
|
|
fallbackBosses,
|
|
nonEmptyZeroBossPackages,
|
|
intentionalEmptyPackages,
|
|
},
|
|
issues: [],
|
|
dungeons,
|
|
};
|
|
report.issues = validateReport(report);
|
|
report.validationStatus = report.issues.length ? "blocked" : "green";
|
|
return report;
|
|
}
|
|
|
|
export async function generateEncounterImplementationReport() {
|
|
const report = await buildEncounterImplementationReport();
|
|
await Promise.all([
|
|
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
|
|
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
|
|
]);
|
|
return {
|
|
report,
|
|
reportJson: projectPath(reportJsonFile),
|
|
reportMarkdown: projectPath(reportMarkdownFile),
|
|
};
|
|
}
|