import { CLASSES, type ClassId, type CoaClassId, } from "../app/characterCatalog"; import { COA_LIVE_SOURCE, COA_TREE_ENTRIES, coaClassDefinition, coaClassIdFromDbcId, coaEntryRank, coaIconUrl, coaSpellById, isCoaClassId, type CoaTreeEntry, } from "./coaLiveCatalog"; import { clampPlayerLevel } from "./progression"; import { WOW335_TALENT_NODES, WOW335_TALENT_TREE_ASSETS } from "./wow335Talents.generated"; export type TalentRanks = Readonly>; export interface TalentTreeDefinition { readonly id: string; readonly classId: ClassId; readonly dbcTalentTabId: number; readonly name: string; readonly icon: string; readonly background: string; readonly description: string; readonly system?: "classic" | "coa"; readonly coaTabId?: number; } /** Raw 3.3.5a Spell.dbc data for one of a talent rank's three effects. */ export interface TalentDbcEffectDefinition { readonly index: number; readonly effectType: number; readonly auraType: number; /** DBC base points normalized to the value displayed by the client. */ readonly basePoints: number; readonly dieSides: number; readonly pointsPerLevel: number; readonly mechanic: number; readonly implicitTargetA: number; readonly implicitTargetB: number; readonly radiusIndex: number; readonly periodMs: number; readonly chainTargets: number; readonly miscValue: number; readonly miscValueB: number; readonly triggerSpellId: number; readonly classMask: readonly [number, number, number]; readonly coefficient: number; } /** Exact local-client spell metadata backing one learned talent rank. */ export interface TalentRankEffectDefinition { readonly spellId: number; readonly rankText: string; readonly procChance: number; readonly procCharges: number; readonly stackAmount: number; readonly maxTargets: number; readonly durationIndex: number; readonly recoveryTimeMs: number; readonly categoryRecoveryTimeMs: number; readonly effects: readonly TalentDbcEffectDefinition[]; } export interface TalentNodeDefinition { readonly id: string; readonly dbcTalentId: number; readonly treeId: string; readonly index: number; readonly prerequisiteId: string | null; readonly prerequisiteRank: number | null; readonly prerequisites: readonly { readonly talentId: string; readonly requiredRank: number; }[]; readonly column: number; readonly row: number; readonly icon: string; readonly iconBasename: string; readonly rankSpellIds: readonly number[]; readonly maxRank: number; readonly name: string; /** Concrete description of the maximum rank, retained for legacy callers. */ readonly description: string; /** Concrete client tooltip for each rank, ordered from rank 1. */ readonly rankDescriptions: readonly string[]; /** Unexpanded client tooltip tokens, retained for provenance/debugging only. */ readonly rawRankDescriptions: readonly string[]; readonly rankEffects: readonly TalentRankEffectDefinition[]; readonly flags: number; readonly requiredSpellId: number; readonly categoryMask: readonly [number, number]; readonly system?: "classic" | "coa"; readonly coaEntryId?: number; readonly entryType?: "Ability" | "Talent"; readonly nodeType?: "SpendCircle" | "SpendSquare" | "SpendHex"; readonly abilityEssenceCost?: number; readonly talentEssenceCost?: number; readonly requiredClassPoints?: number; readonly requiredSpecPoints?: number; readonly connectedNodeIds?: readonly string[]; readonly requiredLevel?: number; readonly isPassive?: boolean; } export type TalentAllocationFailure = | "unknown-talent" | "wrong-class" | "no-points" | "max-rank" | "tier-locked" | "prerequisite" | "level-locked" | "ability-essence" | "talent-essence" | "point-gate"; export interface TalentAllocationResult { readonly ok: boolean; readonly ranks: TalentRanks; readonly reason?: TalentAllocationFailure; } const talentIcon = (basename: string) => `/assets/ui/talents/icons/${basename.toLowerCase()}.png`; const TREE_DETAILS: Readonly> = { "warrior-arms": ["warrior", "Ability_Rogue_Eviscerate", "Master weapons and decisive physical attacks."], "warrior-fury": ["warrior", "Ability_Warrior_InnerRage", "Overwhelm enemies with relentless aggression."], "warrior-protection": ["warrior", "INV_Shield_06", "Endure punishment and protect the party."], "paladin-holy": ["paladin", "Spell_Holy_HolyBolt", "Channel the Light into powerful healing."], "paladin-protection": ["paladin", "Spell_Holy_DevotionAura", "Defend allies with shield and sacred magic."], "paladin-retribution": ["paladin", "Spell_Holy_AuraOfLight", "Judge enemies with holy weapon strikes."], "hunter-beast-mastery": ["hunter", "Ability_Hunter_BeastTaming", "Strengthen the bond between hunter and beast."], "hunter-marksmanship": ["hunter", "Ability_Marksmanship", "Perfect precise and devastating ranged attacks."], "hunter-survival": ["hunter", "Ability_Hunter_SwiftStrike", "Control the battlefield with traps and cunning."], "rogue-assassination": ["rogue", "Ability_Rogue_Eviscerate", "Use poisons and finishers to dispatch targets."], "rogue-combat": ["rogue", "Ability_BackStab", "Excel in sustained weapon combat."], "rogue-subtlety": ["rogue", "Ability_Stealth", "Strike from stealth and manipulate the shadows."], "priest-discipline": ["priest", "Spell_Holy_WordFortitude", "Prevent harm with shields and focused will."], "priest-holy": ["priest", "Spell_Holy_GuardianSpirit", "Restore allies with direct and group healing."], "priest-shadow": ["priest", "Spell_Shadow_ShadowWordPain", "Assault the minds of enemies with shadow magic."], "death-knight-blood": ["death-knight", "Spell_Deathknight_BloodPresence", "Drain life and withstand brutal attacks."], "death-knight-frost": ["death-knight", "Spell_Deathknight_FrostPresence", "Command freezing power and rune weapons."], "death-knight-unholy": ["death-knight", "Spell_Deathknight_UnholyPresence", "Spread disease and forbidden necromancy."], "shaman-elemental": ["shaman", "Spell_Nature_Lightning", "Call lightning, fire, and earth against enemies."], "shaman-enhancement": ["shaman", "Spell_Nature_LightningShield", "Empower weapons through elemental spirits."], "shaman-restoration": ["shaman", "Spell_Nature_MagicImmunity", "Mend allies with water and ancestral magic."], "mage-arcane": ["mage", "Spell_Holy_MagicalSentry", "Shape raw magical energy with precision."], "mage-fire": ["mage", "Spell_Fire_FireBolt02", "Incinerate enemies with explosive flame."], "mage-frost": ["mage", "Spell_Frost_FrostBolt02", "Slow, control, and shatter frozen enemies."], "warlock-affliction": ["warlock", "Spell_Shadow_DeathCoil", "Erode enemies with curses and lasting agony."], "warlock-demonology": ["warlock", "Spell_Shadow_Metamorphosis", "Draw greater power from demonic allies."], "warlock-destruction": ["warlock", "Spell_Shadow_RainOfFire", "Burn enemies with chaotic fire and shadow."], "druid-balance": ["druid", "Spell_Nature_StarFall", "Wield lunar, solar, and natural magic."], "druid-feral-combat": ["druid", "Ability_Racial_BearForm", "Fight in powerful animal forms."], "druid-restoration": ["druid", "Spell_Nature_HealingTouch", "Restore life through natural regeneration."], }; const CLASSIC_TALENT_TREES: readonly TalentTreeDefinition[] = WOW335_TALENT_TREE_ASSETS.map((asset) => { const [classId, icon, description] = TREE_DETAILS[asset.id]; return { id: asset.id, classId, dbcTalentTabId: asset.dbcTalentTabId, name: asset.name, icon: talentIcon(icon), background: asset.background, description, system: "classic", }; }); const CLASSIC_MAX_RANK_BY_ID: ReadonlyMap = new Map( WOW335_TALENT_NODES.map((node) => [node.id, node.rankSpellIds.length]), ); const CLASSIC_TALENT_NODES: readonly TalentNodeDefinition[] = WOW335_TALENT_NODES.map((node) => { // Some patched 3.3.5a trees reduce a prerequisite talent's rank count while // retaining the stock Talent.dbc requirement (for example 5/5 Nature's // Rejuvenation becomes 3/3). Clamp that stale requirement to the imported // prerequisite's real maximum so downstream talents remain reachable. const prerequisites = node.prerequisites .filter((prerequisite) => CLASSIC_MAX_RANK_BY_ID.has(prerequisite.talentId)) .map((prerequisite) => ({ ...prerequisite, requiredRank: Math.min( prerequisite.requiredRank, CLASSIC_MAX_RANK_BY_ID.get(prerequisite.talentId)!, ), })); return { ...node, prerequisiteId: prerequisites[0]?.talentId ?? null, prerequisiteRank: prerequisites[0]?.requiredRank ?? null, prerequisites, maxRank: node.rankSpellIds.length, system: "classic", }; }); function coaTreeId(classId: CoaClassId, tabId: number): string { return `coa-${classId}-${tabId}`; } function coaTalentNodeId(entryId: number): string { return `coa-entry-${entryId}`; } function coaRankEffect(spellId: number): TalentRankEffectDefinition { const dbc = coaSpellById(spellId)?.dbc; return { spellId, rankText: dbc?.rankText ?? "", procChance: dbc?.procChance ?? 0, procCharges: dbc?.procCharges ?? 0, stackAmount: dbc?.stackAmount ?? 0, maxTargets: dbc?.maximumTargets ?? 0, durationIndex: dbc?.durationIndex ?? 0, recoveryTimeMs: dbc?.recoveryTimeMs ?? 0, categoryRecoveryTimeMs: dbc?.categoryRecoveryTimeMs ?? 0, effects: (dbc?.effects ?? []).map((effect) => ({ index: effect.index, effectType: effect.effectType, auraType: effect.auraType, basePoints: effect.basePoints, dieSides: effect.dieSides, pointsPerLevel: effect.pointsPerLevel, mechanic: effect.mechanic, implicitTargetA: effect.implicitTargetA, implicitTargetB: effect.implicitTargetB, radiusIndex: effect.radiusIndex, periodMs: effect.periodMs, chainTargets: effect.chainTargets, miscValue: effect.miscValue, miscValueB: effect.miscValueB, triggerSpellId: effect.triggerSpellId, classMask: [0, 0, 0], coefficient: effect.coefficient, })), }; } function coaRankDescriptions(entry: CoaTreeEntry): readonly CoaRankDescriptionView[] { const maximum = Math.max(1, entry.maxPoints, entry.rankDescriptions.length); return Array.from({ length: maximum }, (_, index) => { const rank = coaEntryRank(entry, index + 1); return { spellId: rank?.spellId ?? entry.rankDescriptions.at(-1)?.spellId ?? 0, description: rank?.description || entry.description || "No live tooltip is available for this rank.", }; }); } interface CoaRankDescriptionView { readonly spellId: number; readonly description: string; } const COA_TALENT_TREES: readonly TalentTreeDefinition[] = CLASSES .filter((definition) => definition.mode === "conquest") .flatMap((definition) => { const classId = definition.id as CoaClassId; return coaClassDefinition(classId).tabs.map((tab) => { const firstEntry = COA_TREE_ENTRIES.find((entry) => ( entry.classId === definition.dbcId && entry.tabId === tab.tabId )); return { id: coaTreeId(classId, tab.tabId), classId, dbcTalentTabId: tab.tabId, name: tab.tabName, icon: coaIconUrl(firstEntry?.iconBasename || "inv_misc_questionmark"), background: "", description: `${tab.tabName} nodes synchronized from ${COA_LIVE_SOURCE.realmName}.`, system: "coa" as const, coaTabId: tab.tabId, }; }); }); const COA_TALENT_NODES: readonly TalentNodeDefinition[] = COA_TREE_ENTRIES.flatMap((entry) => { const classId = coaClassIdFromDbcId(entry.classId); if (!classId) return []; const ranks = coaRankDescriptions(entry); return [{ id: coaTalentNodeId(entry.id), dbcTalentId: entry.id, treeId: coaTreeId(classId, entry.tabId), index: entry.id, prerequisiteId: entry.requiredIds[0] ? coaTalentNodeId(entry.requiredIds[0]) : null, prerequisiteRank: entry.requiredIds[0] ? 1 : null, prerequisites: entry.requiredIds.map((requiredId) => ({ talentId: coaTalentNodeId(requiredId), requiredRank: 1, })), column: Math.max(1, Math.round(entry.x) + 1), row: Math.max(1, Math.round(entry.y) + 1), icon: coaIconUrl(entry.iconBasename || "inv_misc_questionmark"), iconBasename: entry.iconBasename || "inv_misc_questionmark", rankSpellIds: ranks.map((rank) => rank.spellId), maxRank: ranks.length, name: entry.name, description: ranks.at(-1)?.description || entry.description, rankDescriptions: ranks.map((rank) => rank.description), rawRankDescriptions: ranks.map((rank) => rank.description), rankEffects: ranks.map((rank) => coaRankEffect(rank.spellId)), flags: entry.flags, requiredSpellId: 0, categoryMask: [0, 0], system: "coa" as const, coaEntryId: entry.id, entryType: entry.entryType, nodeType: entry.nodeType, abilityEssenceCost: entry.aeCost, talentEssenceCost: entry.teCost, requiredClassPoints: entry.requiredClassPoints, requiredSpecPoints: entry.requiredSpecPoints, connectedNodeIds: entry.connectedNodeIds.map(coaTalentNodeId), requiredLevel: entry.requiredLevel, isPassive: entry.isPassive, }]; }); export const TALENT_TREES: readonly TalentTreeDefinition[] = [ ...CLASSIC_TALENT_TREES, ...COA_TALENT_TREES, ]; export const TALENT_NODES: readonly TalentNodeDefinition[] = [ ...CLASSIC_TALENT_NODES, ...COA_TALENT_NODES, ]; const TREE_BY_ID: Readonly> = Object.freeze( Object.fromEntries(TALENT_TREES.map((definition) => [definition.id, definition])), ); const NODE_BY_ID: Readonly> = Object.freeze( Object.fromEntries(TALENT_NODES.map((definition) => [definition.id, definition])), ); const NODES_BY_TREE: Readonly> = Object.freeze( Object.fromEntries(TALENT_TREES.map((tree) => [ tree.id, TALENT_NODES.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index), ])), ); export function talentTreeById(treeId: string): TalentTreeDefinition | null { return TREE_BY_ID[treeId] ?? null; } export function talentNodeById(nodeId: string): TalentNodeDefinition | null { return NODE_BY_ID[nodeId] ?? null; } export function talentTreesForClass(classId: ClassId): readonly TalentTreeDefinition[] { return TALENT_TREES.filter((definition) => definition.classId === classId); } export function talentNodesForTree(treeId: string): readonly TalentNodeDefinition[] { return NODES_BY_TREE[treeId] ?? []; } export function talentDescriptionForRank(nodeOrId: TalentNodeDefinition | string, rank: number): string { const node = typeof nodeOrId === "string" ? talentNodeById(nodeOrId) : nodeOrId; if (!node) return "Unknown talent."; const normalizedRank = Math.min(node.maxRank, Math.max(1, Math.trunc(Number.isFinite(rank) ? rank : 1))); return node.rankDescriptions[normalizedRank - 1] ?? node.description; } export function talentRankEffect( nodeOrId: TalentNodeDefinition | string, rank: number, ): TalentRankEffectDefinition | null { const node = typeof nodeOrId === "string" ? talentNodeById(nodeOrId) : nodeOrId; if (!node || rank < 1) return null; const normalizedRank = Math.min(node.maxRank, Math.max(1, Math.trunc(Number.isFinite(rank) ? rank : 1))); return node.rankEffects[normalizedRank - 1] ?? null; } export interface ActiveTalentRankEffect extends TalentRankEffectDefinition { readonly talentId: string; readonly talentName: string; readonly treeId: string; readonly rank: number; readonly description: string; } /** Resolves persisted ranks into the exact active Spell.dbc rows for simulation consumers. */ export function activeTalentRankEffects(ranks: TalentRanks, classId?: ClassId): readonly ActiveTalentRankEffect[] { const active: ActiveTalentRankEffect[] = []; for (const [talentId, storedRank] of Object.entries(ranks)) { const node = talentNodeById(talentId); const tree = node ? talentTreeById(node.treeId) : null; if (!node || !tree || (classId && tree.classId !== classId)) continue; const rank = Math.min(node.maxRank, Math.max(0, Math.trunc(Number.isFinite(storedRank) ? storedRank : 0))); const effect = talentRankEffect(node, rank); if (!effect) continue; active.push({ ...effect, talentId: node.id, talentName: node.name, treeId: node.treeId, rank, description: talentDescriptionForRank(node, rank), }); } return active; } /** WotLK grants the first talent point at level 10 and one per level thereafter. */ export function talentPointsForLevel(level: number): number { return Math.max(0, clampPlayerLevel(level) - 9); } export interface CoaEssenceBudget { readonly ability: number; readonly talent: number; readonly maximumAbility: number; readonly maximumTalent: number; } export function coaEssenceBudgetForLevel(classId: CoaClassId, level: number): CoaEssenceBudget { const definition = coaClassDefinition(classId); const normalizedLevel = Math.min(COA_LIVE_SOURCE.maximumLevel, Math.max(1, clampPlayerLevel(level))); // CoA starts at level 10 and alternates a class-tree point (AE) with a // specialization-tree point (TE). Levels 10..60 therefore grant 26/25. const grantedPoints = Math.max(0, normalizedLevel - 9); const ability = Math.min(definition.maxAbilityEssence, Math.ceil(grantedPoints / 2)); const talent = Math.min(definition.maxTalentEssence, Math.floor(grantedPoints / 2)); return { ability, talent, maximumAbility: definition.maxAbilityEssence, maximumTalent: definition.maxTalentEssence, }; } function nodeRankCost(node: TalentNodeDefinition, rank: number): { ability: number; talent: number } { const normalizedRank = Math.min(node.maxRank, Math.max(0, Math.trunc(rank))); return { ability: normalizedRank * (node.abilityEssenceCost ?? 0), talent: normalizedRank * (node.talentEssenceCost ?? 0), }; } export function spentCoaEssence( ranks: TalentRanks, classId: CoaClassId, treeId?: string, ): { readonly ability: number; readonly talent: number } { let ability = 0; let talent = 0; for (const [nodeId, rank] of Object.entries(ranks)) { const node = talentNodeById(nodeId); const tree = node ? talentTreeById(node.treeId) : null; if (!node || !tree || tree.classId !== classId || node.system !== "coa") continue; if (treeId && tree.id !== treeId) continue; const cost = nodeRankCost(node, rank); ability += cost.ability; talent += cost.talent; } return { ability, talent }; } export function unspentCoaEssence( level: number, ranks: TalentRanks, classId: CoaClassId, ): CoaEssenceBudget { const budget = coaEssenceBudgetForLevel(classId, level); const spent = spentCoaEssence(ranks, classId); return { ...budget, ability: Math.max(0, budget.ability - spent.ability), talent: Math.max(0, budget.talent - spent.talent), }; } export function spentTalentPoints(ranks: TalentRanks, classId?: ClassId): number { return Object.entries(ranks).reduce((total, [nodeId, rank]) => { const node = talentNodeById(nodeId); const tree = node ? talentTreeById(node.treeId) : null; if (!node || !tree || (classId && tree.classId !== classId)) return total; const normalizedRank = Math.min(node.maxRank, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); if (node.system === "coa") { const cost = nodeRankCost(node, normalizedRank); return total + cost.ability + cost.talent; } return total + normalizedRank; }, 0); } export function spentTalentPointsInTree(ranks: TalentRanks, treeId: string): number { return talentNodesForTree(treeId).reduce( (total, node) => { const rank = Math.min(node.maxRank, Math.max(0, Math.trunc(ranks[node.id] ?? 0))); if (node.system === "coa") { const cost = nodeRankCost(node, rank); return total + cost.ability + cost.talent; } return total + rank; }, 0, ); } export function unspentTalentPoints(level: number, ranks: TalentRanks, classId?: ClassId): number { if (classId && isCoaClassId(classId)) { const essence = unspentCoaEssence(level, ranks, classId); return essence.ability + essence.talent; } return Math.max(0, talentPointsForLevel(level) - spentTalentPoints(ranks, classId)); } function allocationFailure( ranks: TalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationFailure | null { const node = talentNodeById(nodeId); if (!node) return "unknown-talent"; const tree = talentTreeById(node.treeId); if (!tree || tree.classId !== classId) return "wrong-class"; if ((ranks[node.id] ?? 0) >= node.maxRank) return "max-rank"; if (node.system === "coa") { if (!isCoaClassId(classId)) return "wrong-class"; if ((node.requiredLevel ?? 0) > level) return "level-locked"; const remaining = unspentCoaEssence(level, ranks, classId); if ((node.abilityEssenceCost ?? 0) > remaining.ability) return "ability-essence"; if ((node.talentEssenceCost ?? 0) > remaining.talent) return "talent-essence"; const spentInTree = spentCoaEssence(ranks, classId, node.treeId); if ( spentInTree.ability < (node.requiredClassPoints ?? 0) || spentInTree.talent < (node.requiredSpecPoints ?? 0) ) return "point-gate"; for (const required of node.prerequisites) { const prerequisite = talentNodeById(required.talentId); if (!prerequisite || (ranks[prerequisite.id] ?? 0) < required.requiredRank) return "prerequisite"; } return null; } if (unspentTalentPoints(level, ranks, classId) <= 0) return "no-points"; if (spentTalentPointsInTree(ranks, node.treeId) < (node.row - 1) * 5) return "tier-locked"; for (const required of node.prerequisites) { const prerequisite = talentNodeById(required.talentId); if (!prerequisite || (ranks[prerequisite.id] ?? 0) < required.requiredRank) return "prerequisite"; } return null; } export function talentAllocationFailure( ranks: TalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationFailure | null { return allocationFailure(ranks, classId, nodeId, level); } export function normalizeTalentRanks(classId: ClassId, level: number, ranks: TalentRanks): TalentRanks { let normalized: Record = {}; const requested = new Map(); for (const tree of talentTreesForClass(classId)) { for (const node of talentNodesForTree(tree.id)) { requested.set(node.id, Math.min(node.maxRank, Math.max(0, Math.trunc(ranks[node.id] ?? 0)))); } } let changed = true; while (changed && unspentTalentPoints(level, normalized, classId) > 0) { changed = false; for (const tree of talentTreesForClass(classId)) { for (const node of talentNodesForTree(tree.id)) { while ((normalized[node.id] ?? 0) < (requested.get(node.id) ?? 0)) { if (allocationFailure(normalized, classId, node.id, level)) break; normalized = { ...normalized, [node.id]: (normalized[node.id] ?? 0) + 1 }; changed = true; } } } } return normalized; } export function allocateTalentPoint( ranks: TalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationResult { const normalized = normalizeTalentRanks(classId, level, ranks); const reason = allocationFailure(normalized, classId, nodeId, level); if (reason) return { ok: false, ranks: normalized, reason }; return { ok: true, ranks: { ...normalized, [nodeId]: (normalized[nodeId] ?? 0) + 1 } }; } export function resetTalentRanks(ranks: TalentRanks = {}, classId?: ClassId): TalentRanks { if (!classId) return {}; return Object.fromEntries(Object.entries(ranks).filter(([nodeId]) => { const node = talentNodeById(nodeId); return !node || talentTreeById(node.treeId)?.classId !== classId; })); }