import type { ClassId } from "../app/characterCatalog"; import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression"; import { allocateTalentPoint, talentDescriptionForRank, talentNodeById, talentNodesForTree, talentPointsForLevel, talentTreeById, talentTreesForClass, type TalentRanks, } from "./talentCatalog"; export interface PartyTalentSpecializationDefinition { readonly classId: ClassId; 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: "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: "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: "druid", specialization: "Balance", primaryTreeId: "druid-balance" }, { 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): 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}`); } return [primary, ...trees.filter((tree) => tree.id !== primary.id)] .flatMap((tree) => talentNodesForTree(tree.id)) .map((node) => node.id); } /** * Produces a stable legal build without duplicating talent rules here. The * primary tree is exhausted in catalog row/index order before secondary trees; * every individual rank is accepted by the shared allocator at its real level. */ function buildAllocationPlan( definition: PartyTalentSpecializationDefinition, ): readonly PartyTalentSelection[] { const candidates = orderedCandidateNodeIds(definition); let ranks: TalentRanks = {}; const selections: PartyTalentSelection[] = []; for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) { let selected: PartyTalentSelection | null = null; 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); }