import type { BaseClassId, ClassId } from "../app/characterCatalog"; import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression"; import { allocateTalentPoint, spentTalentPointsInTree, talentDescriptionForRank, talentNodeById, talentNodesForTree, talentPointsForLevel, talentTreeById, talentTreesForClass, type TalentRanks, } from "./talentCatalog"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; export interface PartyTalentSpecializationDefinition { readonly classId: BaseClassId; readonly specialization: string; readonly primaryTreeId: string; } /** * The exact class/spec combinations used by the deterministic dungeon-party * roster. Keep the tree id explicit: display names are not stable identifiers * and Feral (Cat) deliberately maps to the Feral Combat tree. */ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([ { classId: "warrior", specialization: "Protection", primaryTreeId: "warrior-protection" }, { classId: "death-knight", specialization: "Blood", primaryTreeId: "death-knight-blood" }, { classId: "paladin", specialization: "Protection", primaryTreeId: "paladin-protection" }, { classId: "priest", specialization: "Holy", primaryTreeId: "priest-holy" }, { classId: "paladin", specialization: "Holy", primaryTreeId: "paladin-holy" }, { classId: "druid", specialization: "Restoration", primaryTreeId: "druid-restoration" }, { classId: "shaman", specialization: "Restoration", primaryTreeId: "shaman-restoration" }, { classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" }, { classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" }, { classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" }, { classId: "hunter", specialization: "Beast Mastery", primaryTreeId: "hunter-beast-mastery" }, { classId: "hunter", specialization: "Marksmanship", primaryTreeId: "hunter-marksmanship" }, { classId: "hunter", specialization: "Survival", primaryTreeId: "hunter-survival" }, { classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" }, { classId: "rogue", specialization: "Assassination", primaryTreeId: "rogue-assassination" }, { classId: "rogue", specialization: "Subtlety", primaryTreeId: "rogue-subtlety" }, { classId: "priest", specialization: "Discipline", primaryTreeId: "priest-discipline" }, { classId: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" }, { classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" }, { classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" }, { classId: "shaman", specialization: "Enhancement", primaryTreeId: "shaman-enhancement" }, { classId: "shaman", specialization: "Elemental", primaryTreeId: "shaman-elemental" }, { classId: "mage", specialization: "Arcane", primaryTreeId: "mage-arcane" }, { classId: "mage", specialization: "Fire", primaryTreeId: "mage-fire" }, { classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" }, { classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" }, { classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" }, { classId: "warlock", specialization: "Destruction", primaryTreeId: "warlock-destruction" }, { classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" }, { classId: "druid", specialization: "Feral (Bear)", primaryTreeId: "druid-feral-combat" }, { classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" }, ] as const satisfies readonly PartyTalentSpecializationDefinition[]); export interface PartyTalentSelection { /** One-based talent-point number, from 1 at level 10 through 71 at level 80. */ readonly point: number; readonly level: number; readonly nodeId: string; readonly treeId: string; readonly treeName: string; readonly name: string; readonly rank: number; readonly maxRank: number; readonly icon: string; readonly description: string; readonly inPrimaryTree: boolean; } const SPEC_BY_KEY = new Map( PARTY_TALENT_SPECIALIZATIONS.map((definition) => [ specializationKey(definition.classId, definition.specialization), definition, ]), ); const PLAN_BY_KEY = new Map(); function specializationKey(classId: ClassId, specialization: string): string { return `${classId}:${specialization}`; } export function partyTalentSpecialization( classId: ClassId, specialization: string, ): PartyTalentSpecializationDefinition | null { return SPEC_BY_KEY.get(specializationKey(classId, specialization)) ?? null; } export function partyTalentPrimaryTreeId(classId: ClassId, specialization: string): string | null { return partyTalentSpecialization(classId, specialization)?.primaryTreeId ?? null; } function requireSpecialization( classId: ClassId, specialization: string, ): PartyTalentSpecializationDefinition { const definition = partyTalentSpecialization(classId, specialization); if (!definition) throw new Error(`Unsupported party specialization: ${classId}:${specialization}`); return definition; } function orderedCandidateNodeIds( definition: PartyTalentSpecializationDefinition, ranks: TalentRanks, ): readonly string[] { const trees = talentTreesForClass(definition.classId); const primary = trees.find((tree) => tree.id === definition.primaryTreeId); if (!primary) { throw new Error(`Unknown primary talent tree ${definition.primaryTreeId} for ${definition.classId}`); } const primaryNodes = talentNodesForTree(primary.id); const deepestPrimaryRow = Math.max(...primaryNodes.map((node) => node.row)); const reachedDeepestPrimaryRow = primaryNodes.some((node) => ( node.row === deepestPrimaryRow && (ranks[node.id] ?? 0) > 0 )); const primaryComplete = spentTalentPointsInTree(ranks, primary.id) >= 51 && reachedDeepestPrimaryRow; const eligibleTrees = primaryComplete ? trees.filter((tree) => tree.id !== primary.id) : [primary]; const treeOrder = new Map(eligibleTrees.map((tree, index) => [tree.id, index])); const activeTalentIds = new Set( WOW335_ABILITIES_BY_CLASS[definition.classId] .flatMap((ability) => ability.talentNodeId ? [ability.talentNodeId] : []), ); const activePrerequisiteIds = new Set( [...activeTalentIds].flatMap((nodeId) => { const node = talentNodeById(nodeId); return node?.prerequisites.map((entry) => entry.talentId) ?? []; }), ); const capstoneRequirements = new Map(); const addRequirement = (nodeId: string, requiredRank: number): void => { capstoneRequirements.set(nodeId, Math.max(requiredRank, capstoneRequirements.get(nodeId) ?? 0)); const node = talentNodeById(nodeId); for (const prerequisite of node?.prerequisites ?? []) { addRequirement(prerequisite.talentId, prerequisite.requiredRank); } }; const deepestNodes = primaryNodes.filter((node) => node.row === deepestPrimaryRow); const capstoneCandidates = deepestNodes.some((node) => activeTalentIds.has(node.id)) ? deepestNodes.filter((node) => activeTalentIds.has(node.id)) : deepestNodes; const capstoneTarget = [...capstoneCandidates].sort((left, right) => left.index - right.index)[0]; if (capstoneTarget) addRequirement(capstoneTarget.id, 1); const neededCapstonePathIds = new Set( [...capstoneRequirements].filter(([nodeId, requiredRank]) => ( (ranks[nodeId] ?? 0) < requiredRank )).map(([nodeId]) => nodeId), ); return eligibleTrees .flatMap((tree) => talentNodesForTree(tree.id)) .sort((left, right) => ( Number(neededCapstonePathIds.has(right.id)) - Number(neededCapstonePathIds.has(left.id)) || right.row - left.row || Number(activeTalentIds.has(right.id)) - Number(activeTalentIds.has(left.id)) || Number(activePrerequisiteIds.has(right.id)) - Number(activePrerequisiteIds.has(left.id)) || (treeOrder.get(left.treeId) ?? 0) - (treeOrder.get(right.treeId) ?? 0) || left.index - right.index )) .map((node) => node.id); } /** * Produces a stable legal build without duplicating talent rules here. The * primary tree advances to its deepest tier with exactly the 51 points needed * for a Wrath capstone, then spends the remaining 20 points in one secondary * tree. Within each unlocked tier, executable talents and their prerequisites * are preferred. This mirrors the shape of the Playerbots PvE templates while * keeping every rank validated against Healer Man's imported talent catalog. */ function buildAllocationPlan( definition: PartyTalentSpecializationDefinition, ): readonly PartyTalentSelection[] { let ranks: TalentRanks = {}; const selections: PartyTalentSelection[] = []; for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) { let selected: PartyTalentSelection | null = null; const candidates = orderedCandidateNodeIds(definition, ranks); for (const nodeId of candidates) { const node = talentNodeById(nodeId); if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue; const allocation = allocateTalentPoint(ranks, definition.classId, nodeId, level); if (!allocation.ok) continue; ranks = allocation.ranks; const rank = ranks[nodeId] ?? 0; const tree = talentTreeById(node.treeId); if (!tree || rank <= 0) throw new Error(`Talent allocation lost catalog data for ${nodeId}`); selected = Object.freeze({ point: selections.length + 1, level, nodeId, treeId: tree.id, treeName: tree.name, name: node.name, rank, maxRank: node.maxRank, icon: node.icon, description: talentDescriptionForRank(node, rank), inPrimaryTree: tree.id === definition.primaryTreeId, }); break; } if (!selected) { throw new Error(`Could not allocate party talent point for ${definition.classId}:${definition.specialization} at level ${level}`); } selections.push(selected); } return Object.freeze(selections); } /** Returns the complete, catalog-validated 71-point level 10-80 plan. */ export function partyTalentAllocationPlan( classId: ClassId, specialization: string, ): readonly PartyTalentSelection[] { const definition = requireSpecialization(classId, specialization); const key = specializationKey(classId, specialization); const cached = PLAN_BY_KEY.get(key); if (cached) return cached; const plan = buildAllocationPlan(definition); PLAN_BY_KEY.set(key, plan); return plan; } /** Returns current node ranks for a party member at the supplied player level. */ export function partyTalentRanksForLevel( classId: ClassId, specialization: string, level: number, ): TalentRanks { const points = talentPointsForLevel(clampPlayerLevel(level)); const ranks: Record = {}; for (const selection of partyTalentAllocationPlan(classId, specialization).slice(0, points)) { ranks[selection.nodeId] = selection.rank; } return Object.freeze(ranks); } /** Returns every rank learned so far, in the exact level-by-level order learned. */ export function learnedPartyTalentSelections( classId: ClassId, specialization: string, level: number, ): readonly PartyTalentSelection[] { const points = talentPointsForLevel(clampPlayerLevel(level)); return partyTalentAllocationPlan(classId, specialization).slice(0, points); } /** * Returns the next planned ranks after `level`. Before level 10 this starts at * the level-10 choice; at level 80 it is empty. */ export function upcomingPartyTalentSelections( classId: ClassId, specialization: string, level: number, limit = 5, ): readonly PartyTalentSelection[] { const start = talentPointsForLevel(clampPlayerLevel(level)); const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 0; return partyTalentAllocationPlan(classId, specialization).slice(start, start + safeLimit); }