Files
healer-man/scripts/dungeon-pipeline/audit-wow-dungeon-functionality.ts
T
2026-09-09 11:21:33 -04:00

1017 lines
44 KiB
JavaScript

#!/usr/bin/env node
import { access, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DUNGEON_DEFINITIONS } from "../../src/game/dungeonRegistry";
import { validateDungeonEncounterDefinition } from "../../src/game/dungeonEncounterRuntime";
import { validateDungeonWorldObject } from "../../src/game/dungeonWorldObjects";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import type { DungeonEncounterDefinition } from "../../src/game/dungeonEncounterTypes";
interface CampaignCoverageEntry {
readonly dungeonId: string;
readonly source: "azerothcore" | "procedural-fallback";
}
interface CampaignCatalog {
readonly coverage: readonly CampaignCoverageEntry[];
}
interface GameObjectAssetManifest {
readonly summary: {
readonly uniqueAssets: number;
readonly nonVisualInstances: number;
readonly unresolvedModelDisplayIds: number;
readonly unresolvedBoundDisplayIds: number;
};
}
interface ScriptCatalogEntry {
readonly sourceFile: string;
}
interface ScriptCatalog {
readonly scripts: Readonly<Record<string, ScriptCatalogEntry>>;
}
interface RuntimeDraftEntity {
readonly scriptName?: string;
}
interface SourceGameObject {
readonly guid?: number;
readonly id?: number;
}
interface SourceGameObjectTemplate {
readonly entry?: number;
readonly type?: number;
}
interface RuntimeDraft {
readonly entities?: readonly RuntimeDraftEntity[];
readonly gameObjects?: readonly SourceGameObject[];
readonly gameObjectTemplates?: readonly SourceGameObjectTemplate[];
}
type SourceFeature =
| "area-triggers"
| "cinematics-dialogue"
| "doors-gates-bridges"
| "escorts-waypoints"
| "gossip-quests-items"
| "timed-events"
| "vehicles-passengers"
| "waves-summons";
interface DungeonFunctionalityResult {
readonly id: string;
readonly title: string;
readonly expansion: string;
readonly source: CampaignCoverageEntry["source"];
readonly readiness:
| "source-blocked"
| "source-limited-adapted"
| "implementation-required"
| "playtest-required"
| "validated";
readonly validationStatus: string;
readonly population: {
readonly entities: number;
readonly staticSpawns: number;
readonly roamingPacks: number;
readonly objectives: number;
};
readonly combat: {
readonly executable: boolean;
readonly entitiesWithoutCombat: readonly string[];
};
readonly encounters: {
readonly definitions: number;
readonly rules: number;
readonly bossProgressionDefinitions: number;
readonly instanceOrchestrationDefinitions: number;
readonly instanceOrchestrationDefinitionsWithRules: number;
readonly sourceExactInstanceOrchestrationDefinitions: number;
readonly sourceBackedAdaptedInstanceOrchestrationDefinitions: number;
readonly sourceExactDefinitions: number;
readonly sourceBackedAdaptedDefinitions: number;
readonly authoredDefinitions: number;
readonly declaredObjectiveCompletion: boolean;
readonly checkpointState: boolean;
readonly completionState: boolean;
readonly reviewedBehaviors: readonly string[];
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
readonly instanceAdaptations: readonly string[];
readonly instanceRemainingGaps: readonly string[];
readonly spawnGroups: number;
readonly worldObjects: number;
readonly sourceExactWorldObjects: number;
readonly sourceBackedAdaptedWorldObjects: number;
readonly authoredWorldObjects: number;
readonly clientDisplayWorldObjects: number;
readonly clientModelPathWorldObjects: number;
readonly clientBoundedWorldObjects: number;
readonly malformedWorldObjects: number;
};
readonly sourceEvidence: {
readonly rawGameObjects: number;
readonly actionableGameObjects: number;
readonly unresolvedGameObjectTemplates: number;
readonly gameObjectTypes: Readonly<Record<string, number>>;
readonly scriptNames: number;
readonly resolvedScriptFiles: readonly string[];
readonly unresolvedScriptNames: readonly string[];
readonly features: readonly SourceFeature[];
};
readonly navigation: {
readonly assetPresent: boolean;
readonly authoredLinks: number;
};
readonly contractIssues: readonly string[];
readonly requiredChanges: readonly string[];
}
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons");
const sourceScriptsRoot = path.join(workRoot, "azerothcore-scripts/src/server/scripts");
const reportJsonFile = path.join(projectRoot, "dungeon-pipeline", "WOW_DUNGEON_FUNCTIONALITY_REPORT.json");
const reportMarkdownFile = path.join(projectRoot, "dungeon-pipeline", "WOW_DUNGEON_FUNCTIONALITY_REPORT.md");
const wowExporterFile = path.resolve(
projectRoot,
"../wow335a/dungeon-export-work/tools/wow.export-0.2.19/wow.export.exe",
);
const GAME_OBJECT_TYPE_NAMES: Readonly<Record<number, string>> = Object.freeze({
0: "door",
1: "button",
2: "questgiver",
3: "chest-resource",
4: "binder",
5: "generic",
6: "trap",
7: "chair",
8: "spell-focus",
9: "text",
10: "goober",
11: "transport",
12: "area-damage",
13: "camera",
14: "map-object",
15: "moving-transport",
16: "duel-arbiter",
17: "fishing-node",
18: "ritual",
19: "mailbox",
20: "auction-house",
21: "guard-post",
22: "spellcaster",
23: "meeting-stone",
24: "flag-stand",
25: "fishing-hole",
26: "flag-drop",
27: "mini-game",
28: "lottery-kiosk",
29: "capture-point",
30: "aura-generator",
31: "dungeon-difficulty",
32: "barber-chair",
33: "destructible-building",
34: "guild-bank",
35: "trapdoor",
});
const ACTIONABLE_GAME_OBJECT_TYPES = new Set([
0, 1, 2, 3, 6, 10, 11, 12, 13, 15, 18, 22, 23, 31, 33, 35,
]);
const FEATURE_PATTERNS: ReadonlyArray<readonly [SourceFeature, RegExp]> = [
["area-triggers", /\b(?:AreaTrigger|OnTrigger)\b/i],
["cinematics-dialogue", /\b(?:Talk|MonsterYell|SendMovieStart|SAY_|EMOTE_)\b/i],
["doors-gates-bridges", /\b(?:HandleGameObject|SetGoState|GO_STATE_|DoorData|door|gate|drawbridge)\b/i],
["escorts-waypoints", /\b(?:npc_escortAI|EscortAI|WaypointReached|MovementInform|MovePoint|escort)\b/i],
["gossip-quests-items", /\b(?:OnGossip|GossipHello|QuestAccept|QuestReward|HasItemCount|DestroyItemCount|GameObjectScript)\b/i],
["timed-events", /\b(?:EventMap|ScheduleEvent|RescheduleEvent|EVENT_[A-Z0-9_]+)\b/i],
["vehicles-passengers", /\b(?:VehicleAI|EnterVehicle|ExitVehicle|PassengerBoarded|vehicle)\b/i],
["waves-summons", /\b(?:SummonCreature|SummonGameObject|DoSummon|summon|wave)\b/i],
];
const GENERIC_SOURCE_DIRECTORIES = new Set([
"EasternKingdoms",
"Kalimdor",
"Northrend",
"Outland",
"World",
]);
const INSTANCE_SOURCE_ALIASES: Readonly<Record<string, readonly string[]>> = Object.freeze({
"escape-from-durnholde": ["oldhillsbrad"],
"stormwind-stockade": ["thestockade"],
});
// Add a dungeon only after its complete production route, wipe/reset behavior,
// checkpoint reload, and completion state have recorded evidence in
// PLAYTEST_NOTES.md. Asset/package validation is intentionally not sufficient.
const FUNCTIONALLY_VALIDATED_DUNGEON_IDS = new Set<string>();
const PARTIALLY_RECOVERED_ASCENSION_DUNGEON_IDS = new Set([
"tinkertech-showdown",
"frozen-reach",
"forgotten-mine",
]);
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await readFile(file, "utf8")) as T;
}
function relativeFile(file: string): string {
return path.relative(projectRoot, file).replace(/\\/g, "/");
}
async function runtimeDraftFor(dungeonId: string): Promise<RuntimeDraft | null> {
const file = path.join(workRoot, dungeonId, "runtime-draft.json");
return await exists(file) ? readJson<RuntimeDraft>(file) : null;
}
async function filesNamed(root: string, pattern: RegExp): Promise<readonly string[]> {
if (!await exists(root)) return [];
const files: string[] = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
const file = path.join(root, entry.name);
if (entry.isDirectory()) files.push(...await filesNamed(file, pattern));
else if (pattern.test(entry.name)) files.push(file);
}
return files;
}
function normalizedSourceName(value: string): string {
return value.toLowerCase().replace(/^instance[_-]?/, "").replace(/[^a-z0-9]+/g, "");
}
function matchingInstanceFiles(
definition: DungeonDefinition,
instanceFiles: readonly string[],
): readonly string[] {
const variants = [
normalizedSourceName(definition.id),
normalizedSourceName(definition.title),
...(INSTANCE_SOURCE_ALIASES[definition.id] ?? []).map(normalizedSourceName),
].filter((value) => value.length >= 5);
return instanceFiles.filter((file) => {
const sourceName = normalizedSourceName(path.basename(file, path.extname(file)));
return variants.some((variant) => (
sourceName === variant || sourceName.includes(variant) || variant.includes(sourceName)
));
});
}
async function sourceScriptFiles(
definition: DungeonDefinition,
draft: RuntimeDraft | null,
scriptCatalog: ScriptCatalog,
instanceFiles: readonly string[],
): Promise<{
readonly scriptNames: number;
readonly files: readonly string[];
readonly unresolved: readonly string[];
readonly source: string;
}> {
const names = [...new Set((draft?.entities ?? [])
.map((entity) => entity.scriptName?.trim())
.filter((name): name is string => Boolean(name)))];
const unresolved = names.filter((name) => !scriptCatalog.scripts[name]?.sourceFile);
const directFiles = [...new Set([
...names
.map((name) => scriptCatalog.scripts[name]?.sourceFile)
.filter((file): file is string => Boolean(file))
.map((file) => path.resolve(projectRoot, file)),
...matchingInstanceFiles(definition, instanceFiles),
])];
const candidates = new Set(directFiles);
for (const directFile of directFiles) {
const directory = path.dirname(directFile);
if (GENERIC_SOURCE_DIRECTORIES.has(path.basename(directory)) || !await exists(directory)) continue;
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isFile() && /\.(?:cpp|h)$/i.test(entry.name)) candidates.add(path.join(directory, entry.name));
}
}
const files: string[] = [];
const source: string[] = [];
for (const file of candidates) {
if (!await exists(file)) continue;
files.push(relativeFile(file));
source.push(await readFile(file, "utf8"));
}
return {
scriptNames: names.length,
files: files.sort(),
unresolved: unresolved.sort(),
source: source.join("\n"),
};
}
function sourceGameObjectEvidence(draft: RuntimeDraft | null): {
readonly raw: number;
readonly actionable: number;
readonly unresolvedTemplates: number;
readonly types: Readonly<Record<string, number>>;
} {
const templates = new Map((draft?.gameObjectTemplates ?? [])
.map((template) => [Number(template.entry), template]));
let actionable = 0;
let unresolvedTemplates = 0;
const types: Record<string, number> = {};
for (const object of draft?.gameObjects ?? []) {
const template = templates.get(Number(object.id));
if (!template) {
unresolvedTemplates += 1;
types.unknown = (types.unknown ?? 0) + 1;
continue;
}
const type = Number(template.type);
const label = GAME_OBJECT_TYPE_NAMES[type] ?? `type-${type}`;
types[label] = (types[label] ?? 0) + 1;
if (ACTIONABLE_GAME_OBJECT_TYPES.has(type)) actionable += 1;
}
return {
raw: draft?.gameObjects?.length ?? 0,
actionable,
unresolvedTemplates,
types: Object.freeze(Object.fromEntries(Object.entries(types).sort())),
};
}
function unique(values: readonly string[]): readonly string[] {
return [...new Set(values)];
}
function encounterCounts(definition: DungeonDefinition): {
readonly definitions: number;
readonly rules: number;
readonly bossProgressionDefinitions: number;
readonly instanceOrchestrationDefinitions: number;
readonly instanceOrchestrationDefinitionsWithRules: number;
readonly sourceExactInstanceOrchestrationDefinitions: number;
readonly sourceBackedAdaptedInstanceOrchestrationDefinitions: number;
readonly sourceExactDefinitions: number;
readonly sourceBackedAdaptedDefinitions: number;
readonly authoredDefinitions: number;
readonly declaredObjectiveCompletion: boolean;
readonly checkpointState: boolean;
readonly completionState: boolean;
readonly reviewedBehaviors: readonly string[];
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
readonly instanceAdaptations: readonly string[];
readonly instanceRemainingGaps: readonly string[];
readonly spawnGroups: number;
readonly malformedWorldObjects: number;
} {
let malformedWorldObjects = 0;
for (const object of definition.serverGameObjects) {
try {
validateDungeonWorldObject(object);
} catch {
malformedWorldObjects += 1;
}
}
for (const encounter of definition.encounters ?? []) validateDungeonEncounterDefinition(encounter);
const encounters = definition.encounters ?? [];
const actions = encounters.flatMap((encounter) => encounter.rules.flatMap((rule) => rule.actions));
const instanceOrchestration = encounters.filter(
(encounter) => encounter.source?.scope === "instance-orchestration",
);
return {
definitions: encounters.length,
rules: encounters.reduce((count, encounter) => count + encounter.rules.length, 0),
bossProgressionDefinitions: encounters.filter(
(encounter) => encounter.source?.scope === "boss-progression",
).length,
instanceOrchestrationDefinitions: instanceOrchestration.length,
instanceOrchestrationDefinitionsWithRules: instanceOrchestration.filter(
(encounter) => encounter.rules.length > 0,
).length,
sourceExactInstanceOrchestrationDefinitions: instanceOrchestration.filter(
(encounter) => encounter.source?.fidelity === "source-exact",
).length,
sourceBackedAdaptedInstanceOrchestrationDefinitions: instanceOrchestration.filter(
(encounter) => encounter.source?.fidelity === "source-backed-adapted",
).length,
sourceExactDefinitions: encounters.filter(
(encounter) => encounter.source?.fidelity === "source-exact",
).length,
sourceBackedAdaptedDefinitions: encounters.filter(
(encounter) => encounter.source?.fidelity === "source-backed-adapted",
).length,
authoredDefinitions: encounters.filter(
(encounter) => encounter.source?.fidelity === "healer-man-authored",
).length,
declaredObjectiveCompletion: actions.some((action) => action.kind === "complete-objective"),
checkpointState: actions.some((action) => action.kind === "set-checkpoint"),
completionState: actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)),
reviewedBehaviors: unique(encounters.flatMap((encounter) => (
encounter.source?.reviewedBehavior ? [encounter.source.reviewedBehavior] : []
))),
adaptations: unique(encounters.flatMap((encounter) => encounter.source?.adaptations ?? [])),
remainingGaps: unique(encounters.flatMap((encounter) => encounter.source?.remainingGaps ?? [])),
instanceAdaptations: unique(instanceOrchestration.flatMap(
(encounter) => encounter.source?.adaptations ?? [],
)),
instanceRemainingGaps: unique(instanceOrchestration.flatMap(
(encounter) => encounter.source?.remainingGaps ?? [],
)),
spawnGroups: definition.encounterSpawnGroups?.length ?? 0,
malformedWorldObjects,
};
}
function requiredChangesFor(input: {
readonly definition: DungeonDefinition;
readonly source: CampaignCoverageEntry["source"];
readonly sourceObjects: ReturnType<typeof sourceGameObjectEvidence>;
readonly features: readonly SourceFeature[];
readonly scriptFiles: readonly string[];
readonly malformedWorldObjects: number;
readonly sourceBackedAdaptedWorldObjects: number;
readonly clientModelPathWorldObjects: number;
readonly uniqueClientModelPaths: number;
readonly clientBoundedWorldObjects: number;
readonly instanceOrchestrationDefinitions: number;
readonly remainingInstanceOrchestrationGaps: readonly string[];
readonly contractIssues: readonly string[];
}): readonly string[] {
const {
definition,
source,
sourceObjects,
features,
scriptFiles,
malformedWorldObjects,
sourceBackedAdaptedWorldObjects,
clientModelPathWorldObjects,
uniqueClientModelPaths,
clientBoundedWorldObjects,
instanceOrchestrationDefinitions,
remainingInstanceOrchestrationGaps,
contractIssues,
} = input;
const changes: string[] = [];
const needsMoreInstanceWork = instanceOrchestrationDefinitions === 0
|| remainingInstanceOrchestrationGaps.length > 0;
if (malformedWorldObjects) {
changes.push("Remove raw/malformed database rows from the live world-object renderer contract.");
}
if (source === "procedural-fallback") {
changes.push(PARTIALLY_RECOVERED_ASCENSION_DUNGEON_IDS.has(definition.id)
? "Import the still-missing authoritative Ascension creature templates, spawn coordinates, object placements, and server cadence to replace the explicitly documented authored portions; preserve the recovered checksummed identities, spell families, and summon links."
: "Acquire and import an authoritative Ascension creature, spawn, object, and encounter snapshot.");
}
if (sourceObjects.raw > 0 && definition.serverGameObjects.length < sourceObjects.actionable) {
changes.push(`Compile ${sourceObjects.actionable - definition.serverGameObjects.length} remaining actionable source object instances into typed world objects.`);
}
if (sourceBackedAdaptedWorldObjects > 0) {
changes.push(`Review ${sourceBackedAdaptedWorldObjects} source-backed adapted objects: export ${uniqueClientModelPaths} unique client models used by ${clientModelPathWorldObjects} instances, resolve ${sourceBackedAdaptedWorldObjects - clientModelPathWorldObjects} missing model paths and ${sourceBackedAdaptedWorldObjects - clientBoundedWorldObjects} missing bounds, then enable reviewed collision and instance-script state transitions.`);
}
if (scriptFiles.length > 0 && instanceOrchestrationDefinitions === 0) {
changes.push("Review and port the dungeon instance/NPC orchestration into serializable DungeonEncounterDefinition rules.");
} else if (scriptFiles.length > 0 && remainingInstanceOrchestrationGaps.length > 0) {
changes.push("Close the documented remaining instance-orchestration gaps without regressing the runtime-wired source-backed adaptation.");
}
if (features.includes("doors-gates-bridges") && needsMoreInstanceWork) {
changes.push("Bind encounter progress to door, gate, bridge, collision, and line-of-sight state transitions.");
}
if (features.includes("waves-summons") && needsMoreInstanceWork) {
changes.push("Author exact summon groups, wave order, despawn rules, timings, and wipe resets.");
}
if (features.includes("escorts-waypoints") && needsMoreInstanceWork) {
changes.push("Add escort/waypoint movement, pauses, dialogue gates, failure, and resume/reset behavior.");
}
if (features.includes("vehicles-passengers") && needsMoreInstanceWork) {
changes.push("Add source-backed vehicle seats, possession/action controls, passenger events, and dismount cleanup.");
}
if (features.includes("gossip-quests-items") && needsMoreInstanceWork) {
changes.push("Add gossip, quest, item/key, and object-use prerequisites required by the instance flow.");
}
if (features.includes("area-triggers") && needsMoreInstanceWork) {
changes.push("Port required area-trigger volumes and one-shot/reset behavior.");
}
if (!definition.assets.navigation) {
changes.push("Generate and package a navigation asset.");
}
if (definition.navigationLinks.length === 0 && definition.validation.status !== "green") {
changes.push("Run entrance-to-final-boss traversal and author any required off-mesh links or dynamic blockers.");
}
if (contractIssues.length) {
changes.push("Repair population/objective/runtime contract violations before playtesting.");
}
if (!FUNCTIONALLY_VALIDATED_DUNGEON_IDS.has(definition.id)) {
changes.push("Complete desktop runtime, combat, wipe/reset, reload/checkpoint, HUD, collision, and completion playtests before setting validation green.");
}
return unique(changes);
}
function readinessFor(
definition: DungeonDefinition,
source: CampaignCoverageEntry["source"],
requiredChanges: readonly string[],
instanceOrchestrationDefinitions: number,
): DungeonFunctionalityResult["readiness"] {
if (source === "procedural-fallback") {
return instanceOrchestrationDefinitions > 0
? "source-limited-adapted"
: "source-blocked";
}
if (requiredChanges.some((change) => /Compile|Review|Port|Add |Bind|Author exact/i.test(change))) {
return "implementation-required";
}
if (!FUNCTIONALLY_VALIDATED_DUNGEON_IDS.has(definition.id) || requiredChanges.length) return "playtest-required";
return "validated";
}
function markdownList(values: readonly string[]): string {
return values.length ? values.join(", ") : "none";
}
function reportMarkdown(report: {
readonly generatedAt: string;
readonly status: string;
readonly summary: Readonly<Record<string, number>>;
readonly tooling: {
readonly wowExporter: {
readonly available: boolean;
readonly expectedPath: string;
};
};
readonly capabilityBacklog: readonly {
readonly priority: string;
readonly capability: string;
readonly affectedDungeons: readonly string[];
readonly editTargets: readonly string[];
}[];
readonly dungeons: readonly DungeonFunctionalityResult[];
}): string {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.title,
"|", dungeon.source === "azerothcore" ? "source-backed" : "procedural",
"|", dungeon.readiness,
"|", `${dungeon.encounters.definitions}/${dungeon.encounters.rules}`,
"|", `${dungeon.encounters.worldObjects}/${dungeon.sourceEvidence.rawGameObjects}/${dungeon.sourceEvidence.actionableGameObjects}`,
"|", markdownList(dungeon.sourceEvidence.features), "|",
].join(" "));
const capabilitySections = report.capabilityBacklog.flatMap((entry) => [
`### ${entry.priority}: ${entry.capability}`,
"",
`Affected (${entry.affectedDungeons.length}): ${markdownList(entry.affectedDungeons)}`,
"",
`Edit targets: ${entry.editTargets.map((target) => `\`${target}\``).join(", ")}`,
"",
]);
const dungeonSections = report.dungeons.flatMap((dungeon) => [
`### ${dungeon.title}`,
"",
`Readiness: **${dungeon.readiness}**. Runtime encounter definitions/rules: ${dungeon.encounters.definitions}/${dungeon.encounters.rules}. Source objects/actionable: ${dungeon.sourceEvidence.rawGameObjects}/${dungeon.sourceEvidence.actionableGameObjects}.`,
"",
"Source review findings:",
"",
...dungeon.encounters.reviewedBehaviors.map((behavior) => `- ${behavior}`),
...(dungeon.encounters.reviewedBehaviors.length ? [""] : ["- None recorded.", ""]),
"Implemented adaptations:",
"",
...dungeon.encounters.adaptations.map((adaptation) => `- ${adaptation}`),
...(dungeon.encounters.adaptations.length ? [""] : ["- None recorded.", ""]),
"Remaining source-fidelity gaps:",
"",
...dungeon.encounters.remainingGaps.map((gap) => `- ${gap}`),
...(dungeon.encounters.remainingGaps.length ? [""] : ["- None recorded.", ""]),
"Required next changes:",
"",
...dungeon.requiredChanges.map((change) => `- ${change}`),
...(dungeon.requiredChanges.length ? [""] : ["- No remaining functionality gaps detected by this audit.", ""]),
]);
return [
"# WoW dungeon functionality validation",
"",
`Generated: ${report.generatedAt}`,
"",
`Status: **${report.status}**`,
"",
"This is the production-fidelity gate. It complements the combat/animation report by auditing live runtime contracts, source game objects, instance scripts, encounters, traversal, resets, and required player interactions.",
"",
"## Findings",
"",
`- ${report.summary.dungeons} WoW dungeons audited; ${report.summary.validated} currently meet this functionality gate.`,
`- ${report.summary.sourceBacked} have authoritative population sources; ${report.summary.sourceBlocked} are playable, provenance-labeled Ascension adaptations that still require external server data for exact parity.`,
`- ${report.summary.dungeonsWithDeclaredBossProgression} dungeons declare boss-progression, checkpoint, and completion actions; ${report.summary.dungeonsWithoutEncounterRuntime} have no encounter runtime. Lifecycle tests and live traversal evidence, not declaration counts, determine executability.`,
"- Run completion is consumed by the live HUD: the player may finish looting, then explicitly exit and clear the saved dungeon checkpoint.",
`- ${report.summary.dungeonsWithReviewedInstanceOrchestration} ${report.summary.dungeonsWithReviewedInstanceOrchestration === 1 ? "dungeon has" : "dungeons have"} source-reviewed instance behavior; ${report.summary.dungeonsWithInstanceOrchestrationRules} contain orchestration rules beyond the fleet baseline and ${report.summary.dungeonsWithVerifiedNoOpInstanceScripts} have a verified no-op instance script. These are declaration counts, not traversal claims; adaptations and remaining gaps are recorded below.`,
`- ${report.summary.dungeonsWithoutReviewedInstanceOrchestration} dungeons still lack an instance-level source review; ${report.summary.dungeonsWithDocumentedInstanceGaps} retain explicitly documented instance-fidelity gaps after adaptation.`,
`- ${report.summary.rawSourceGameObjects} AzerothCore object spawns were found, including ${report.summary.actionableSourceGameObjects} actionable doors, buttons, chests/resources, transports, traps, and related objects.`,
`- ${report.summary.typedRuntimeWorldObjects} typed runtime world objects are compiled, including ${report.summary.sourceBackedAdaptedWorldObjects} source-backed adapted records. ${report.summary.clientModelPathWorldObjects} instances resolve to ${report.summary.uniqueGameObjectAssets} unique client models and ${report.summary.clientBoundedWorldObjects} have exact client bounds; ${report.summary.malformedRuntimeWorldObjects} malformed raw objects reach the live renderer.`,
`- The deterministic game-object export manifest deduplicates the visual workload into ${report.summary.uniqueGameObjectAssets} client assets, records ${report.summary.nonVisualGameObjectInstances} intentional no-display spell-focus records, and isolates ${report.summary.unresolvedGameObjectModelDisplayIds} unresolved display IDs plus ${report.summary.unresolvedGameObjectBoundDisplayIds} display IDs without usable client bounds.`,
report.tooling.wowExporter.available
? `- The patched client exporter is available at \`${report.tooling.wowExporter.expectedPath}\`.`
: `- Native game-object model conversion is blocked until the patched client exporter is restored at \`${report.tooling.wowExporter.expectedPath}\`.`,
`- ${report.summary.dungeonsWithAuthoredNavigationLinks} ${report.summary.dungeonsWithAuthoredNavigationLinks === 1 ? "dungeon has" : "dungeons have"} reviewed off-mesh links; the remaining routes still require end-to-end traversal evidence.`,
"",
"## Capability backlog",
"",
...capabilitySections,
"## Fleet matrix",
"",
"Encounter runtime is shown as definitions/rules. Objects are shown as typed runtime/source rows/actionable source rows.",
"",
"| Dungeon | Population source | Readiness | Encounter runtime | Objects | Source systems |",
"| --- | --- | --- | ---: | ---: | --- |",
...rows,
"",
"## Per-dungeon required changes",
"",
...dungeonSections,
].join("\n");
}
const campaign = await readJson<CampaignCatalog>(
path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json"),
);
const scriptCatalog = await readJson<ScriptCatalog>(
path.join(projectRoot, "src/game/generated/azerothCoreScriptSpells.json"),
);
const gameObjectAssetManifest = await readJson<GameObjectAssetManifest>(
path.join(projectRoot, "dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json"),
);
const coverageByDungeon = new Map(campaign.coverage.map((entry) => [entry.dungeonId, entry]));
const instanceFiles = await filesNamed(sourceScriptsRoot, /^instance_.+\.cpp$/i);
const definitions = DUNGEON_DEFINITIONS
.filter((definition) => definition.expansion !== "runewaker")
.sort((left, right) => left.mapId - right.mapId || left.id.localeCompare(right.id));
const dungeons: DungeonFunctionalityResult[] = [];
for (const definition of definitions) {
const coverage = coverageByDungeon.get(definition.id);
if (!coverage) throw new Error(`${definition.id}: missing generated campaign coverage.`);
const draft = await runtimeDraftFor(definition.id);
const scripts = await sourceScriptFiles(definition, draft, scriptCatalog, instanceFiles);
const features = FEATURE_PATTERNS
.filter(([, pattern]) => pattern.test(scripts.source))
.map(([feature]) => feature);
const sourceObjects = sourceGameObjectEvidence(draft);
const counts = encounterCounts(definition);
const contractIssues: string[] = [];
const spawnIds = new Set<string>();
for (const spawn of definition.staticSpawns) {
if (spawnIds.has(spawn.id)) contractIssues.push(`Duplicate spawn id ${spawn.id}.`);
spawnIds.add(spawn.id);
if (!definition.entities[spawn.entityId]) contractIssues.push(`${spawn.id} references missing entity ${spawn.entityId}.`);
}
for (const pack of definition.roamingPacks) {
for (const member of pack.members) {
const id = `${pack.id}:${member.id}`;
if (spawnIds.has(id)) contractIssues.push(`Duplicate spawn id ${id}.`);
spawnIds.add(id);
if (!definition.entities[member.entityId]) contractIssues.push(`${id} references missing entity ${member.entityId}.`);
}
}
const bossIds = new Set<string>();
for (const boss of definition.bosses) {
if (bossIds.has(boss.id)) contractIssues.push(`Duplicate boss id ${boss.id}.`);
bossIds.add(boss.id);
}
const completedObjectiveIds = new Set((definition.encounters ?? []).flatMap((encounter) => (
encounter.rules.flatMap((rule) => rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
)))
)));
const objectiveIds = new Set<string>();
for (const objective of definition.objectiveOrder) {
if (objectiveIds.has(objective.id)) {
contractIssues.push(`Duplicate objective id ${objective.id}.`);
}
objectiveIds.add(objective.id);
if (!completedObjectiveIds.has(objective.id)) {
contractIssues.push(`Objective ${objective.id} has no encounter completion action.`);
}
if (!objective.eventOnly && !objective.optional && !spawnIds.has(objective.id)) {
contractIssues.push(`Required objective ${objective.id} has no matching runtime spawn.`);
}
}
const entitiesWithoutCombat = Object.values(definition.entities)
.filter((entity) => !entity.combat?.attacks?.length)
.map((entity) => entity.id)
.sort();
if (counts.malformedWorldObjects) {
contractIssues.push(`${counts.malformedWorldObjects} malformed world-object records reach the live renderer.`);
}
const requiredChanges = requiredChangesFor({
definition,
source: coverage.source,
sourceObjects,
features,
scriptFiles: scripts.files,
malformedWorldObjects: counts.malformedWorldObjects,
sourceBackedAdaptedWorldObjects: definition.serverGameObjects.filter(
(object) => object.source?.fidelity === "source-backed-adapted",
).length,
clientModelPathWorldObjects: definition.serverGameObjects.filter(
(object) => Boolean(object.source?.clientModelPath),
).length,
uniqueClientModelPaths: new Set(definition.serverGameObjects
.map((object) => object.source?.clientModelPath?.toLowerCase())
.filter(Boolean)).size,
clientBoundedWorldObjects: definition.serverGameObjects.filter(
(object) => Boolean(object.source?.clientBounds),
).length,
instanceOrchestrationDefinitions: counts.instanceOrchestrationDefinitions,
remainingInstanceOrchestrationGaps: counts.instanceRemainingGaps,
contractIssues,
});
const sourceExactWorldObjects = definition.serverGameObjects.filter(
(object) => object.source?.fidelity === "source-exact",
).length;
const sourceBackedAdaptedWorldObjects = definition.serverGameObjects.filter(
(object) => object.source?.fidelity === "source-backed-adapted",
).length;
const authoredWorldObjects = definition.serverGameObjects.filter(
(object) => object.source?.fidelity === "healer-man-authored",
).length;
const clientDisplayWorldObjects = definition.serverGameObjects.filter(
(object) => Boolean(object.source?.clientDisplayId),
).length;
const clientModelPathWorldObjects = definition.serverGameObjects.filter(
(object) => Boolean(object.source?.clientModelPath),
).length;
const clientBoundedWorldObjects = definition.serverGameObjects.filter(
(object) => Boolean(object.source?.clientBounds),
).length;
dungeons.push({
id: definition.id,
title: definition.title,
expansion: definition.expansion,
source: coverage.source,
readiness: readinessFor(
definition,
coverage.source,
requiredChanges,
counts.instanceOrchestrationDefinitions,
),
validationStatus: definition.validation.status,
population: {
entities: Object.keys(definition.entities).length,
staticSpawns: definition.staticSpawns.length,
roamingPacks: definition.roamingPacks.length,
objectives: definition.objectiveOrder.length,
},
combat: {
executable: entitiesWithoutCombat.length === 0,
entitiesWithoutCombat,
},
encounters: {
definitions: counts.definitions,
rules: counts.rules,
bossProgressionDefinitions: counts.bossProgressionDefinitions,
instanceOrchestrationDefinitions: counts.instanceOrchestrationDefinitions,
instanceOrchestrationDefinitionsWithRules: counts.instanceOrchestrationDefinitionsWithRules,
sourceExactInstanceOrchestrationDefinitions: counts.sourceExactInstanceOrchestrationDefinitions,
sourceBackedAdaptedInstanceOrchestrationDefinitions: counts.sourceBackedAdaptedInstanceOrchestrationDefinitions,
sourceExactDefinitions: counts.sourceExactDefinitions,
sourceBackedAdaptedDefinitions: counts.sourceBackedAdaptedDefinitions,
authoredDefinitions: counts.authoredDefinitions,
declaredObjectiveCompletion: counts.declaredObjectiveCompletion,
checkpointState: counts.checkpointState,
completionState: counts.completionState,
reviewedBehaviors: counts.reviewedBehaviors,
adaptations: counts.adaptations,
remainingGaps: counts.remainingGaps,
instanceAdaptations: counts.instanceAdaptations,
instanceRemainingGaps: counts.instanceRemainingGaps,
spawnGroups: counts.spawnGroups,
worldObjects: definition.serverGameObjects.length,
sourceExactWorldObjects,
sourceBackedAdaptedWorldObjects,
authoredWorldObjects,
clientDisplayWorldObjects,
clientModelPathWorldObjects,
clientBoundedWorldObjects,
malformedWorldObjects: counts.malformedWorldObjects,
},
sourceEvidence: {
rawGameObjects: sourceObjects.raw,
actionableGameObjects: sourceObjects.actionable,
unresolvedGameObjectTemplates: sourceObjects.unresolvedTemplates,
gameObjectTypes: sourceObjects.types,
scriptNames: scripts.scriptNames,
resolvedScriptFiles: scripts.files,
unresolvedScriptNames: scripts.unresolved,
features,
},
navigation: {
assetPresent: Boolean(definition.assets.navigation),
authoredLinks: definition.navigationLinks.length,
},
contractIssues: unique(contractIssues),
requiredChanges,
});
}
const affected = (predicate: (dungeon: DungeonFunctionalityResult) => boolean) => (
dungeons.filter(predicate).map((dungeon) => dungeon.title)
);
const capabilityBacklog = [
{
priority: "P0",
capability: "Compile source game objects into the typed runtime contract",
affectedDungeons: affected((dungeon) => (
dungeon.encounters.worldObjects < dungeon.sourceEvidence.actionableGameObjects
)),
editTargets: [
"scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
"src/game/dungeonWorldObjects.ts",
"src/scene/DungeonWorldObjects.tsx",
],
},
{
priority: "P0",
capability: "Export reviewed client visuals and enable collision/state transitions for adapted objects",
affectedDungeons: affected((dungeon) => dungeon.encounters.sourceBackedAdaptedWorldObjects > 0),
editTargets: [
"dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json",
"scripts/dungeon-pipeline/export-source.mjs",
"scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
"src/game/dungeonWorldObjects.ts",
"src/scene/DungeonWorldObjects.tsx",
],
},
{
priority: "P1",
capability: "Review and port unreviewed instance scripts",
affectedDungeons: affected((dungeon) => (
dungeon.sourceEvidence.resolvedScriptFiles.length > 0
&& dungeon.encounters.instanceOrchestrationDefinitions === 0
)),
editTargets: [
"scripts/dungeon-pipeline/import-azerothcore-scripts.mjs",
"src/game/dungeonEncounterTypes.ts",
"src/game/dungeonEncounterRuntime.ts",
],
},
{
priority: "P1",
capability: "Close documented source-script orchestration gaps",
affectedDungeons: affected((dungeon) => (
dungeon.sourceEvidence.resolvedScriptFiles.length > 0
&& dungeon.encounters.instanceOrchestrationDefinitions > 0
&& dungeon.encounters.instanceRemainingGaps.length > 0
)),
editTargets: [
"src/game/<dungeon encounter definitions>",
"src/game/dungeonEncounterTypes.ts",
"src/game/dungeonEncounterRuntime.ts",
],
},
{
priority: "P1",
capability: "Add exact waves, summons, escorts, and vehicle primitives",
affectedDungeons: affected((dungeon) => dungeon.sourceEvidence.features.some((feature) => (
feature === "waves-summons" || feature === "escorts-waypoints" || feature === "vehicles-passengers"
))),
editTargets: [
"src/game/dungeonEncounterTypes.ts",
"src/game/dungeonEncounterRuntime.ts",
"src/game/dungeonEncounterSpawns.ts",
"src/scene/PartyPopulation.tsx",
],
},
{
priority: "P1",
capability: "Add object, quest, key, chest/resource, and area-trigger interactions",
affectedDungeons: affected((dungeon) => (
dungeon.sourceEvidence.actionableGameObjects > 0
|| dungeon.sourceEvidence.features.includes("gossip-quests-items")
|| dungeon.sourceEvidence.features.includes("area-triggers")
)),
editTargets: [
"src/game/dungeonWorldInteraction.ts",
"src/game/dungeonWorldObjects.ts",
"src/game/dungeonEncounterRuntime.ts",
"src/ui/InteractionPrompt.tsx",
],
},
{
priority: "P1",
capability: "Replace procedural Ascension populations with authoritative data",
affectedDungeons: affected((dungeon) => dungeon.source === "procedural-fallback"),
editTargets: [
"dungeon-pipeline/work/<dungeon>/source-snapshot.json",
"scripts/dungeon-pipeline/import-five-player-dungeon.mjs",
"scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
],
},
{
priority: "P2",
capability: "Run end-to-end traversal, collision, wipe/reset, checkpoint, and completion playtests",
affectedDungeons: affected((dungeon) => dungeon.readiness !== "validated"),
editTargets: [
"src/game/<dungeon encounter definitions>",
"src/game/dungeonNavigation.ts",
"PLAYTEST_NOTES.md",
],
},
].filter((entry) => entry.affectedDungeons.length > 0);
const summary = {
dungeons: dungeons.length,
validated: dungeons.filter((dungeon) => dungeon.readiness === "validated").length,
sourceBacked: dungeons.filter((dungeon) => dungeon.source === "azerothcore").length,
sourceBlocked: dungeons.filter((dungeon) => dungeon.source === "procedural-fallback").length,
dungeonsWithEncounterRuntime: dungeons.filter((dungeon) => dungeon.encounters.definitions > 0).length,
dungeonsWithoutEncounterRuntime: dungeons.filter((dungeon) => dungeon.encounters.definitions === 0).length,
dungeonsWithDeclaredBossProgression: dungeons.filter(
(dungeon) => dungeon.encounters.declaredObjectiveCompletion
&& dungeon.encounters.checkpointState
&& dungeon.encounters.completionState,
).length,
dungeonsWithReviewedInstanceOrchestration: dungeons.filter(
(dungeon) => dungeon.encounters.instanceOrchestrationDefinitions > 0,
).length,
dungeonsWithoutReviewedInstanceOrchestration: dungeons.filter(
(dungeon) => dungeon.encounters.instanceOrchestrationDefinitions === 0,
).length,
dungeonsWithInstanceOrchestrationRules: dungeons.filter(
(dungeon) => dungeon.encounters.instanceOrchestrationDefinitionsWithRules > 0,
).length,
dungeonsWithVerifiedNoOpInstanceScripts: dungeons.filter((dungeon) => (
dungeon.encounters.instanceOrchestrationDefinitions > 0
&& dungeon.encounters.instanceOrchestrationDefinitionsWithRules === 0
&& dungeon.encounters.sourceExactInstanceOrchestrationDefinitions > 0
)).length,
dungeonsWithDocumentedInstanceGaps: dungeons.filter(
(dungeon) => dungeon.encounters.instanceRemainingGaps.length > 0,
).length,
sourceExactEncounterDefinitions: dungeons.reduce(
(count, dungeon) => count + dungeon.encounters.sourceExactDefinitions,
0,
),
sourceBackedAdaptedEncounterDefinitions: dungeons.reduce(
(count, dungeon) => count + dungeon.encounters.sourceBackedAdaptedDefinitions,
0,
),
authoredEncounterDefinitions: dungeons.reduce(
(count, dungeon) => count + dungeon.encounters.authoredDefinitions,
0,
),
rawSourceGameObjects: dungeons.reduce((count, dungeon) => count + dungeon.sourceEvidence.rawGameObjects, 0),
actionableSourceGameObjects: dungeons.reduce((count, dungeon) => count + dungeon.sourceEvidence.actionableGameObjects, 0),
typedRuntimeWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.worldObjects, 0),
sourceExactWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.sourceExactWorldObjects, 0),
sourceBackedAdaptedWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.sourceBackedAdaptedWorldObjects, 0),
authoredWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.authoredWorldObjects, 0),
clientDisplayWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.clientDisplayWorldObjects, 0),
clientModelPathWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.clientModelPathWorldObjects, 0),
clientBoundedWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.clientBoundedWorldObjects, 0),
uniqueGameObjectAssets: gameObjectAssetManifest.summary.uniqueAssets,
nonVisualGameObjectInstances: gameObjectAssetManifest.summary.nonVisualInstances,
unresolvedGameObjectModelDisplayIds: gameObjectAssetManifest.summary.unresolvedModelDisplayIds,
unresolvedGameObjectBoundDisplayIds: gameObjectAssetManifest.summary.unresolvedBoundDisplayIds,
malformedRuntimeWorldObjects: dungeons.reduce((count, dungeon) => count + dungeon.encounters.malformedWorldObjects, 0),
dungeonsWithAuthoredNavigationLinks: dungeons.filter((dungeon) => dungeon.navigation.authoredLinks > 0).length,
contractIssues: dungeons.reduce((count, dungeon) => count + dungeon.contractIssues.length, 0),
};
const tooling = {
wowExporter: {
available: await exists(wowExporterFile),
expectedPath: relativeFile(wowExporterFile),
},
};
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: summary.validated === summary.dungeons ? "green" : "blocked",
scope: "WoW five-player production functionality",
summary,
tooling,
capabilityBacklog,
dungeons,
};
await writeFile(reportJsonFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeFile(reportMarkdownFile, `${reportMarkdown(report).trimEnd()}\n`, "utf8");
console.log(JSON.stringify({
status: report.status,
reportJson: relativeFile(reportJsonFile),
reportMarkdown: relativeFile(reportMarkdownFile),
summary,
}, null, 2));