Files
healer-man/src/game/talentCatalog.ts
T
2026-09-18 16:09:25 -04:00

610 lines
24 KiB
TypeScript

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<Record<string, number>>;
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<Record<string, readonly [ClassId, string, string]>> = {
"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<string, number> = new Map<string, number>(
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<Record<string, TalentTreeDefinition>> = Object.freeze(
Object.fromEntries(TALENT_TREES.map((definition) => [definition.id, definition])),
);
const NODE_BY_ID: Readonly<Record<string, TalentNodeDefinition>> = Object.freeze(
Object.fromEntries(TALENT_NODES.map((definition) => [definition.id, definition])),
);
const NODES_BY_TREE: Readonly<Record<string, readonly TalentNodeDefinition[]>> = 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<string, number> = {};
const requested = new Map<string, number>();
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;
}));
}