614 lines
20 KiB
TypeScript
614 lines
20 KiB
TypeScript
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,
|
|
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<ClassId> = new Set(BASE_CLASS_IDS);
|
|
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
|
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
|
|
|
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
|
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
|
> = 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<PartyRole, Readonly<Partial<Record<AbilityAiCategory, number>>>>
|
|
> = 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<Record<PartyRole, readonly CoaRoleTextPattern[]>> = 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<string, readonly AbilityDefinition[]> {
|
|
const mutable = new Map<string, AbilityDefinition[]>();
|
|
for (const ability of abilitiesForClass(classId)) {
|
|
if (ability.talentEntryId === undefined) continue;
|
|
const nodeId = `coa-entry-${ability.talentEntryId}`;
|
|
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<string, readonly AbilityDefinition[]>,
|
|
): 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<string, readonly AbilityDefinition[]>,
|
|
): number {
|
|
return coaNodeRoleScore(node.id, role, abilitiesByNode) + coaNodeTextRoleScore(node, role);
|
|
}
|
|
|
|
function coaTreeRoleScore(
|
|
tree: TalentUiTree,
|
|
catalog: TalentUiCatalog,
|
|
role: PartyRole,
|
|
abilitiesByNode: ReadonlyMap<string, readonly AbilityDefinition[]>,
|
|
): 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<string, readonly AbilityDefinition[]>,
|
|
): 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<string, readonly AbilityDefinition[]>,
|
|
): 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<string, readonly AbilityDefinition[]>,
|
|
): RuntimeTalentRanks {
|
|
const candidates = orderedCoaAllocationCandidates(
|
|
catalog,
|
|
preferredTree,
|
|
role,
|
|
abilitiesByNode,
|
|
);
|
|
const successfulRequests: Record<string, number> = {};
|
|
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<string, number> = {};
|
|
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<typeof contentCategoryForClass>;
|
|
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<BenchmarkBuildDefinition[]> {
|
|
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<ClassId, TalentUiCatalog>(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<BenchmarkBuildDefinition[]> {
|
|
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<CoaClassId, TalentUiCatalog>(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;
|
|
}
|