272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
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<string, PartyTalentSpecializationDefinition>(
|
|
PARTY_TALENT_SPECIALIZATIONS.map((definition) => [
|
|
specializationKey(definition.classId, definition.specialization),
|
|
definition,
|
|
]),
|
|
);
|
|
const PLAN_BY_KEY = new Map<string, readonly PartyTalentSelection[]>();
|
|
|
|
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<string, number>();
|
|
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<string, number> = {};
|
|
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);
|
|
}
|