153 lines
7.5 KiB
TypeScript
153 lines
7.5 KiB
TypeScript
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));
|
|
}
|