forked from phenom/healer-man
Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,702 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user