forked from phenom/healer-man
610 lines
24 KiB
TypeScript
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;
|
|
}));
|
|
}
|