import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { abilitiesForClass, abilityTalentNodeId } from "../../src/game/abilityCatalog"; import { abilityHasRotationSemantics, rotationProfileFor } from "../../src/game/combatRotation"; import { BOSS_TESTING_CATALOG } from "../../src/game/bossTesting"; import { coaClassDefinition, coaEntriesForClass, coaSpellById, COA_LIVE_SOURCE, } from "../../src/game/coaLiveCatalog"; import { COA_FIRST_PARTY_MEMBERS, coaFirstPartySimulationConfig, loadCoaFirstPartyBuilds, } from "../../src/game/benchmark/coaFirstParty"; import type { CoaClassId } from "../../src/app/characterCatalog"; const root = fileURLToPath(new URL("../../", import.meta.url)); const args = process.argv.slice(2); let level = 60; let listDungeon: string | undefined; const bossKeys: string[] = []; for (let index = 0; index < args.length; index += 2) { const flag = args[index]; const value = args[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}.`); if (flag === "--level") level = Number(value); else if (flag === "--boss") bossKeys.push(value); else if (flag === "--list-bosses") listDungeon = value; else throw new Error(`Unknown argument ${flag}.`); } if (listDungeon) { if (bossKeys.length) throw new Error("Use either --list-bosses or explicit --boss keys."); const bosses = BOSS_TESTING_CATALOG.filter((boss) => boss.dungeonId === listDungeon && boss.category === "wow"); if (!bosses.length) throw new Error(`No WoW bosses found for ${listDungeon}.`); console.log(JSON.stringify(bosses.map(({ key, name }) => ({ key, name })), null, 2)); } else { // An explicit boss list prevents preparation from becoming an all-boss run. const selectedBosses = bossKeys.map((key) => { const boss = BOSS_TESTING_CATALOG.find((candidate) => candidate.key === key && candidate.category === "wow"); if (!boss) throw new Error(`Unknown WoW boss key: ${key}.`); return { key: boss.key, name: boss.name }; }); const config = coaFirstPartySimulationConfig(level, bossKeys); const builds = await loadCoaFirstPartyBuilds(level); const sourceBytes = await readFile(resolve(root, "src/game/coaLiveData.generated.json")); const sourceSha256 = createHash("sha256").update(sourceBytes).digest("hex"); const classes = COA_FIRST_PARTY_MEMBERS.map((member, slot) => { const classId = member.classId as CoaClassId; const definition = coaClassDefinition(classId); const abilities = abilitiesForClass(classId); const build = builds[slot]; const firstPassTabs = new Set(definition.tabs .filter((tab) => tab.tabName === "Class" || tab.tabName === member.specialization) .map((tab) => tab.tabId)); const nodes = coaEntriesForClass(classId).map((entry) => { const nodeId = `coa-entry-${entry.id}`; const mapped = abilities.filter((ability) => abilityTalentNodeId(ability) === nodeId); const effects = mapped.flatMap((ability) => [ ...ability.effects, ...(ability.ranks ?? []).flatMap((rank) => rank.effects), ]); const spellIds = [...new Set(entry.rankDescriptions.map((rank) => rank.spellId))]; return { nodeId, sourceNodeId: entry.id, name: entry.name, tree: definition.tabs.find((tab) => tab.tabId === entry.tabId)?.tabName ?? String(entry.tabId), phase: firstPassTabs.has(entry.tabId) ? "first-party" : "remaining-trees", description: entry.description, rankDescriptions: entry.rankDescriptions, spellIds, missingGlobalSpellIds: spellIds.filter((id) => !coaSpellById(id)?.dbc), selectedRank: build.talentRanks[nodeId] ?? 0, runtimeAbilityIds: mapped.map((ability) => ability.id), effectKinds: [...new Set(effects.map((effect) => effect.kind))].sort(), hasRotationSemantics: mapped.some(abilityHasRotationSemantics), sourceOperationsNeedingReview: effects.filter((effect) => ( effect.kind === "scripted" || effect.kind === "unsupported" || (effect.kind === "utility" && effect.action === "class-script") )), // A catalog match cannot establish working class mechanics. mechanicVerification: "needs-review", }; }); return { ...member, slot, buildId: build.id, talentRanks: build.talentRanks, talentAllocation: "generated role-weighted diagnostic build; requires authored review", rotationProfileId: rotationProfileFor(classId, member.specializationId, member.specialization)?.id ?? null, readiness: "needs-testing", counts: { classNodes: nodes.length, firstPartyNodes: nodes.filter((node) => node.phase === "first-party").length, nodesWithoutRuntimeAbility: nodes.filter((node) => node.runtimeAbilityIds.length === 0).length, nodesWithRetainedSourceOperations: nodes.filter((node) => node.sourceOperationsNeedingReview.length > 0).length, sourceGapNodes: nodes.filter((node) => node.missingGlobalSpellIds.length > 0).length, }, nodes, }; }); const report = { schemaVersion: 1, milestone: "coa-first-party", level, source: { realm: COA_LIVE_SOURCE.realmName, capturedAt: COA_LIVE_SOURCE.synchronizedAt, catalogSha256: sourceSha256, spellDbcPath: COA_LIVE_SOURCE.coaSpellDbcPath, }, scope: "Explicit five-class diagnostic; no readiness promotion or balance certification.", bosses: selectedBosses, classes, }; const output = resolve(root, "artifacts/coa-first-party", `level-${level}`); await mkdir(output, { recursive: true }); const saveJson = (name: string, value: unknown) => writeFile(resolve(output, name), `${JSON.stringify(value, null, 2)}\n`); await saveJson("audit.json", report); await saveJson("config.json", config); for (let slot = 0; slot < COA_FIRST_PARTY_MEMBERS.length; slot += 1) { await saveJson(`seat-${slot + 1}.json`, coaFirstPartySimulationConfig(level, bossKeys, slot)); } const markdown = [ "# First CoA party — mechanic review inventory", "", `Level ${level}; ${COA_LIVE_SOURCE.realmName} capture ${COA_LIVE_SOURCE.synchronizedAt}.`, "Catalog mappings below are review inventory, not implemented-mechanic counts.", "All five remain Needs Testing. Generated talent allocations need authored review.", "", "| Class | Specialization | Role | All class nodes | First-party nodes | Nodes with retained source operations | Global source-gap nodes |", "| --- | --- | --- | ---: | ---: | ---: | ---: |", ...classes.map((entry) => `| ${entry.classId} | ${entry.specialization} | ${entry.role} | ${entry.counts.classNodes} | ${entry.counts.firstPartyNodes} | ${entry.counts.nodesWithRetainedSourceOperations} | ${entry.counts.sourceGapNodes} |`), "", "## Repeatable diagnostics", "", `Bosses: ${selectedBosses.map((boss) => `${boss.name} (${boss.key})`).join(", ")}.`, "Five seat configurations keep the same classes and build IDs while moving the active player.", "The full node checklist, spell IDs, descriptions, and source-operation review flags are in audit.json.", "No encounter simulation runs during preparation.", "", `Source catalog SHA-256: ${sourceSha256}.`, ].join("\n"); await writeFile(resolve(output, "audit.md"), `${markdown}\n`); console.log(JSON.stringify({ output, classes: classes.map(({ classId, specialization, counts }) => ({ classId, specialization, counts })), configurations: 5, simulatedEncounters: 0 }, null, 2)); }