import { BASE_CLASS_IDS, CLASSES, COA_CLASS_IDS, ROM_CLASS_IDS, classById, contentCategoryForClass, type BaseClassId, type ClassId, type CoaClassId, type RomClassId, } from "../../app/characterCatalog"; import { abilitiesForClass, abilityTalentNodeId, preloadAbilityCatalogsForClasses, type AbilityAiHints, type AbilityDefinition, } from "../abilityCatalog"; import { aiHintsForAbility } from "../abilityRules"; import type { EquipmentAssignments } from "../equipment"; import { partyRolesForClass, type PartyRole } from "../partyRoles"; import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime"; import { clampPlayerLevel } from "../progression"; import { specializationsForClass, type SpecializationDefinition, } from "../specializationCatalog"; import { runtimeAllocateTalentPoint, runtimeNormalizeTalentRanks, type RuntimeTalentRanks, } from "../talentRuntimeCatalog"; import { loadTalentUiCatalog, type TalentUiCatalog, type TalentUiNode, type TalentUiTree, } from "../talentUiCatalog"; import type { BenchmarkBuildDefinition } from "./benchmarkTypes"; export const CANONICAL_BENCHMARK_LEVEL = 60; export const CANONICAL_BENCHMARK_GEAR_PRESET_ID = "coefficient-empty"; const EMPTY_INVENTORY: readonly never[] = Object.freeze([]); const EMPTY_EQUIPMENT: EquipmentAssignments = Object.freeze({}); const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({}); const BASE_CLASS_ID_SET: ReadonlySet = new Set(BASE_CLASS_IDS); const COA_CLASS_ID_SET: ReadonlySet = new Set(COA_CLASS_IDS); const ROM_CLASS_ID_SET: ReadonlySet = new Set(ROM_CLASS_IDS); const WOW_ORIENTATION_BY_ROLE: Readonly< Record>>> > = Object.freeze({ warrior: { tank: "Protection", damage: "Fury" }, paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" }, hunter: { damage: "Marksmanship" }, rogue: { damage: "Combat" }, priest: { healer: "Holy", damage: "Shadow" }, "death-knight": { tank: "Blood", damage: "Frost" }, shaman: { healer: "Restoration", damage: "Elemental" }, mage: { damage: "Fire" }, warlock: { damage: "Affliction" }, druid: { tank: "Feral (Cat)", healer: "Restoration", damage: "Balance" }, }); type AbilityAiCategory = AbilityAiHints["categories"][number]; /** * The large gaps keep the primary responsibility of a role ahead of merely * having more utility buttons. These are only selection weights; allocation * legality and point costs remain entirely owned by the runtime talent system. */ const COA_ROLE_CATEGORY_WEIGHTS: Readonly< Record>>> > = Object.freeze({ tank: Object.freeze({ taunt: 1_000, mitigation: 500, control: 50, interrupt: 50, heal: 20, "emergency-heal": 20, dispel: 15, debuff: 10, execute: 10, damage: 10, }), healer: Object.freeze({ "emergency-heal": 800, "area-heal": 700, heal: 500, mitigation: 100, dispel: 80, resurrection: 60, control: 20, interrupt: 15, debuff: 5, damage: 2, }), damage: Object.freeze({ execute: 200, damage: 100, debuff: 40, interrupt: 30, control: 20, mitigation: 3, }), }); interface CoaRoleTextPattern { readonly pattern: RegExp; readonly weight: number; } // CoA specialization trees contain many passive nodes with no executable // ability (and consequently no AbilityAiHints). Catalog text is a stable // secondary signal for those nodes; executable ability hints still contribute // directly whenever the runtime catalog provides them. const COA_ROLE_TEXT_PATTERNS: Readonly> = Object.freeze({ tank: Object.freeze([ { pattern: /\b(?:taunt|threat)\b/i, weight: 300 }, { pattern: /\b(?:damage (?:you )?take|damage taken|incoming damage|less damage)\b/i, weight: 160 }, { pattern: /\b(?:armor|block|parry|dodge|deflect)\b/i, weight: 90 }, { pattern: /\b(?:absorb|shield|barrier)\b/i, weight: 70 }, { pattern: /\b(?:maximum health|max health|stamina)\b/i, weight: 50 }, { pattern: /\b(?:protect|guard)\w*\b/i, weight: 35 }, { pattern: /\b(?:resistan\w*|immune)\b/i, weight: 20 }, { pattern: /\b(?:healing received|heal(?:s|ing)? you|restore\w* health)\b/i, weight: 15 }, ]), healer: Object.freeze([ { pattern: /\b(?:resurrect|revive)\w*\b/i, weight: 200 }, { pattern: /\b(?:heal|healing|restore health|recovery)\w*\b/i, weight: 100 }, { pattern: /\b(?:dispel|cleanse|purif)\w*\b/i, weight: 60 }, { pattern: /\b(?:absorb|shield)\w*\b/i, weight: 30 }, { pattern: /\b(?:ally|allies|party member)\b/i, weight: 25 }, ]), damage: Object.freeze([ { pattern: /\b(?:damage|damaging)\w*\b/i, weight: 25 }, { pattern: /\b(?:attack power|spell power|critical strike|critical damage|haste)\b/i, weight: 12 }, { pattern: /\b(?:enemy|enemies)\b/i, weight: 5 }, ]), }); function isBaseClassId(classId: ClassId): classId is BaseClassId { return BASE_CLASS_ID_SET.has(classId); } function isCoaClassId(classId: ClassId): classId is CoaClassId { return COA_CLASS_ID_SET.has(classId); } function isRomClassId(classId: ClassId): classId is RomClassId { return ROM_CLASS_ID_SET.has(classId); } function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): string { return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`; } function benchmarkVariantBuildId( classId: ClassId, role: PartyRole, orientation: string, level: number, ): string { const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`; } function commonBuildFields(classId: ClassId, role: PartyRole, level: number) { return { id: benchmarkBuildId(classId, role, level), source: contentCategoryForClass(classId), classId, secondaryClassId: null, role, level, inventory: EMPTY_INVENTORY, equipment: EMPTY_EQUIPMENT, gearPresetId: CANONICAL_BENCHMARK_GEAR_PRESET_ID, } as const; } function wowBuild( classId: BaseClassId, role: PartyRole, level: number, ): BenchmarkBuildDefinition { const orientation = WOW_ORIENTATION_BY_ROLE[classId][role]; if (!orientation) { throw new Error(`No canonical WoW talent orientation exists for ${classId}:${role}.`); } return Object.freeze({ ...commonBuildFields(classId, role, level), orientation, talentRanks: runtimePartyTalentRanksForLevel(classId, orientation, level), }); } function isClassTree(tree: TalentUiTree): boolean { return tree.name.trim().toLowerCase() === "class"; } function coaNodeAbilities(classId: CoaClassId): ReadonlyMap { const mutable = new Map(); for (const ability of abilitiesForClass(classId)) { const nodeId = abilityTalentNodeId(ability); if (!nodeId) continue; const existing = mutable.get(nodeId); if (existing) existing.push(ability); else mutable.set(nodeId, [ability]); } return mutable; } function coaAbilityRoleScore(ability: AbilityDefinition, role: PartyRole): number { const hints = aiHintsForAbility(ability); const weights = COA_ROLE_CATEGORY_WEIGHTS[role]; const categoryScore = hints.categories.reduce( (total, category) => total + (weights[category] ?? 0), 0, ); if (categoryScore <= 0) return 0; const priority = Number.isFinite(hints.priority) ? Math.max(0, Math.min(100, hints.priority ?? 0)) : 0; return categoryScore + priority; } function coaNodeRoleScore( nodeId: string, role: PartyRole, abilitiesByNode: ReadonlyMap, ): number { return (abilitiesByNode.get(nodeId) ?? []).reduce( (highest, ability) => Math.max(highest, coaAbilityRoleScore(ability, role)), 0, ); } function coaNodeTextRoleScore(node: TalentUiNode, role: PartyRole): number { const searchable = `${node.name} ${node.description}`; return COA_ROLE_TEXT_PATTERNS[role].reduce( (total, entry) => total + (entry.pattern.test(searchable) ? entry.weight : 0), 0, ); } function coaNodeSelectionScore( node: TalentUiNode, role: PartyRole, abilitiesByNode: ReadonlyMap, ): number { return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role); } function coaTreeRoleScore( tree: TalentUiTree, catalog: TalentUiCatalog, role: PartyRole, abilitiesByNode: ReadonlyMap, ): number { return catalog.nodes.reduce((total, node) => ( node.treeId === tree.id ? total + coaNodeSelectionScore(node, role, abilitiesByNode) * node.maxRank : total ), 0); } function preferredCoaTree( catalog: TalentUiCatalog, role: PartyRole, abilitiesByNode: ReadonlyMap, ): TalentUiTree { const specializationTrees = catalog.trees.filter((tree) => !isClassTree(tree)); const candidates = specializationTrees.length > 0 ? specializationTrees : catalog.trees; const catalogOrder = new Map(catalog.trees.map((tree, index) => [tree.id, index])); const selected = [...candidates].sort((left, right) => ( coaTreeRoleScore(right, catalog, role, abilitiesByNode) - coaTreeRoleScore(left, catalog, role, abilitiesByNode) || (catalogOrder.get(left.id) ?? 0) - (catalogOrder.get(right.id) ?? 0) || left.id.localeCompare(right.id) ))[0]; if (!selected) throw new Error(`No CoA talent trees exist for ${catalog.classId}.`); return selected; } function orderedCoaAllocationCandidates( catalog: TalentUiCatalog, preferredTree: TalentUiTree, role: PartyRole, abilitiesByNode: ReadonlyMap, ): readonly TalentUiNode[] { const classTreeIds = new Set(catalog.trees.filter(isClassTree).map((tree) => tree.id)); const treePriority = (node: TalentUiNode) => ( node.treeId === preferredTree.id ? 0 : classTreeIds.has(node.treeId) ? 1 : 2 ); return catalog.nodes .filter((node) => node.treeId === preferredTree.id || classTreeIds.has(node.treeId)) .sort((left, right) => ( treePriority(left) - treePriority(right) || coaNodeSelectionScore(right, role, abilitiesByNode) - coaNodeSelectionScore(left, role, abilitiesByNode) || left.row - right.row || left.column - right.column || left.index - right.index || left.id.localeCompare(right.id) )); } function allocateCoaTalentRanks( classId: CoaClassId, catalog: TalentUiCatalog, preferredTree: TalentUiTree, role: PartyRole, level: number, abilitiesByNode: ReadonlyMap, ): RuntimeTalentRanks { const candidates = orderedCoaAllocationCandidates( catalog, preferredTree, role, abilitiesByNode, ); const successfulRequests: Record = {}; let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS; // A successful request is never repeated, even if normalization later omits // a free node. This keeps zero-cost prerequisite layouts finite while failed // gated nodes remain eligible after lower rows have been filled. while (true) { let allocated = false; for (const node of candidates) { if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue; const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level); if (!result.ok) continue; successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1; ranks = result.ranks; allocated = true; break; } if (!allocated) break; } return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) }); } function coaBuild( classId: CoaClassId, role: PartyRole, level: number, catalog: TalentUiCatalog, ): BenchmarkBuildDefinition { const abilitiesByNode = coaNodeAbilities(classId); const orientation = preferredCoaTree(catalog, role, abilitiesByNode); return Object.freeze({ ...commonBuildFields(classId, role, level), orientation: orientation.name, talentRanks: allocateCoaTalentRanks( classId, catalog, orientation, role, level, abilitiesByNode, ), }); } function romBuild( classId: RomClassId, role: PartyRole, level: number, ): BenchmarkBuildDefinition { return Object.freeze({ ...commonBuildFields(classId, role, level), orientation: "Primary", talentRanks: EMPTY_TALENT_RANKS, }); } function specializationTree( catalog: TalentUiCatalog, specialization: SpecializationDefinition, ): TalentUiTree { const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, ""); const names = new Set([ normalized(specialization.talentTree), normalized(specialization.displayName), ]); const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name))); if (!tree) { throw new Error( `No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`, ); } return tree; } function allocateSingleTreeTalentRanks( classId: BaseClassId, catalog: TalentUiCatalog, tree: TalentUiTree, level: number, ): RuntimeTalentRanks { const candidates = catalog.nodes .filter((node) => node.treeId === tree.id) .sort((left, right) => ( left.row - right.row || left.column - right.column || left.index - right.index || left.id.localeCompare(right.id) )); const successfulRequests: Record = {}; let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS; while (true) { let allocated = false; for (const node of candidates) { if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue; const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level); if (!result.ok) continue; successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1; ranks = result.ranks; allocated = true; break; } if (!allocated) break; } return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) }); } function selectableClassicBuild( specialization: SpecializationDefinition, level: number, catalog: TalentUiCatalog | null, ): BenchmarkBuildDefinition { const classId = specialization.classId as BaseClassId; let talentRanks: RuntimeTalentRanks; try { talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level); } catch { if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`); talentRanks = allocateSingleTreeTalentRanks( classId, catalog, specializationTree(catalog, specialization), level, ); } return Object.freeze({ ...commonBuildFields(classId, specialization.role, level), id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level), orientation: specialization.displayName, talentRanks, }); } function selectableCoaBuild( specialization: SpecializationDefinition, level: number, catalog: TalentUiCatalog, ): BenchmarkBuildDefinition { const classId = specialization.classId as CoaClassId; const tree = specializationTree(catalog, specialization); const abilitiesByNode = coaNodeAbilities(classId); return Object.freeze({ ...commonBuildFields(classId, specialization.role, level), id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level), orientation: specialization.displayName, talentRanks: allocateCoaTalentRanks( classId, catalog, tree, specialization.role, level, abilitiesByNode, ), }); } export interface SelectableBenchmarkBuildCatalogEntry { readonly id: string; readonly source: ReturnType; readonly classId: ClassId; readonly role: PartyRole; readonly orientation: string; readonly level: number; } /** Cheap metadata-only roster used by interactive tools before a build is selected. */ export function selectableBenchmarkBuildCatalog( level = CANONICAL_BENCHMARK_LEVEL, classIds?: readonly ClassId[], ): readonly SelectableBenchmarkBuildCatalogEntry[] { const safeLevel = clampPlayerLevel(level); const requested = classIds ? new Set(classIds) : null; return CLASSES .map((definition) => definition.id) .filter((classId) => !requested || requested.has(classId)) .flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => { if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({ id: benchmarkBuildId(classId, role, safeLevel), source: contentCategoryForClass(classId), classId, role, orientation: "Primary", level: safeLevel, })); return specializationsForClass(classId).map((specialization) => ({ id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel), source: contentCategoryForClass(classId), classId, role: specialization.role, orientation: specialization.displayName, level: safeLevel, })); }); } /** * Builds every user-selectable class/spec/role variant for the balance lab. * This is intentionally broader than the canonical benchmark cohort above, * which keeps one representative orientation per class-role pair. */ export async function loadSelectableBenchmarkBuilds( level = CANONICAL_BENCHMARK_LEVEL, classIds?: readonly ClassId[], ): Promise { const safeLevel = clampPlayerLevel(level); const requested = classIds ? new Set(classIds) : null; const selectedClassIds = CLASSES .map((definition) => definition.id) .filter((classId) => !requested || requested.has(classId)); const catalogClassIds = selectedClassIds.filter((classId) => ( isCoaClassId(classId) || (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => { try { runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel); return false; } catch { return true; } })) )); await preloadAbilityCatalogsForClasses(selectedClassIds); const catalogs = new Map(await Promise.all( catalogClassIds.map(async (classId) => [ classId, await loadTalentUiCatalog(classId), ] as const), )); const builds: BenchmarkBuildDefinition[] = []; for (const classId of selectedClassIds) { if (isRomClassId(classId)) { for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel)); continue; } for (const specialization of specializationsForClass(classId)) { if (isBaseClassId(classId)) { builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null)); } else if (isCoaClassId(classId)) { const catalog = catalogs.get(classId); if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`); builds.push(selectableCoaBuild(specialization, safeLevel, catalog)); } } } return builds; } /** * Creates one stable, legal benchmark build for every declared class-role pair. * A filter changes only membership; output always follows the canonical CLASSES * and partyRolesForClass declaration order. */ export async function loadCanonicalBenchmarkBuilds( level = CANONICAL_BENCHMARK_LEVEL, classIds?: readonly ClassId[], ): Promise { const safeLevel = clampPlayerLevel(level); const requested = classIds ? new Set(classIds) : null; const selectedClassIds = CLASSES .map((definition) => definition.id) .filter((classId) => !requested || requested.has(classId)); const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId); await preloadAbilityCatalogsForClasses(selectedCoaClassIds); const coaCatalogs = new Map(await Promise.all( selectedCoaClassIds.map(async (classId) => [ classId, await loadTalentUiCatalog(classId), ] as const), )); const builds: BenchmarkBuildDefinition[] = []; for (const classId of selectedClassIds) { for (const role of partyRolesForClass(classId)) { if (isBaseClassId(classId)) { builds.push(wowBuild(classId, role, safeLevel)); } else if (isCoaClassId(classId)) { const catalog = coaCatalogs.get(classId); if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`); builds.push(coaBuild(classId, role, safeLevel, catalog)); } else if (isRomClassId(classId)) { builds.push(romBuild(classId, role, safeLevel)); } } } return builds; }