1017 lines
44 KiB
JavaScript
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));
|