import { CLASSES, type ClassId, type CoaClassId } from "../app/characterCatalog"; import { clampPlayerLevel } from "./progression"; import type { TalentAllocationFailure, TalentAllocationResult, } from "./talentCatalog"; export type RuntimeTalentRanks = Readonly>; export interface RuntimeTalentTree { readonly id: string; readonly classId: ClassId; } export interface RuntimeTalentNode { readonly id: string; readonly treeId: string; readonly index: number; readonly row: number; readonly maxRank: number; readonly name: string; readonly prerequisites: readonly { readonly talentId: string; readonly requiredRank: number }[]; readonly rankDescriptions: readonly string[]; readonly system?: "classic" | "coa"; readonly abilityEssenceCost?: number; readonly talentEssenceCost?: number; readonly requiredClassPoints?: number; readonly requiredSpecPoints?: number; readonly requiredLevel?: number; readonly isPassive?: boolean; } interface RuntimeTalentSnapshot { readonly schemaVersion: 1; readonly maximumCoaLevel: number; readonly coaBudgetsByClass: Readonly>; readonly trees: readonly RuntimeTalentTree[]; readonly nodes: readonly RuntimeTalentNode[]; } let snapshot: RuntimeTalentSnapshot = { schemaVersion: 1, maximumCoaLevel: 80, coaBudgetsByClass: {} as RuntimeTalentSnapshot["coaBudgetsByClass"], trees: [], nodes: [], }; export let RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees; export let RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes; let treeById: Readonly> = {}; let nodeById: Readonly> = {}; let nodesByTree: Readonly> = {}; let catalogLoad: Promise | null = null; /** The authored WoW/CoA talent snapshot is unnecessary for a RoM-only session. */ export function preloadRuntimeTalentCatalog(): Promise { if (catalogLoad) return catalogLoad; catalogLoad = import("./generated/talentRuntimeCatalog.json").then(({ default: generated }) => { const loaded = generated as unknown as RuntimeTalentSnapshot; if (loaded.schemaVersion !== 1) throw new Error("Unsupported runtime talent catalog."); snapshot = loaded; RUNTIME_TALENT_TREES = snapshot.trees; RUNTIME_TALENT_NODES = snapshot.nodes; treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))); nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))); nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [ tree.id, snapshot.nodes.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index), ]))); }).catch((error: unknown) => { catalogLoad = null; throw error; }); return catalogLoad; } function isCoaClassId(classId: ClassId): classId is CoaClassId { return CLASSES.find((definition) => definition.id === classId)?.mode === "conquest"; } export function runtimeTalentNodeById(nodeId: string): RuntimeTalentNode | null { return nodeById[nodeId] ?? null; } function treesForClass(classId: ClassId): readonly RuntimeTalentTree[] { return snapshot.trees.filter((tree) => tree.classId === classId); } function nodesForTree(treeId: string): readonly RuntimeTalentNode[] { return nodesByTree[treeId] ?? []; } export interface RuntimeActiveTalentRankEffect { readonly talentId: string; readonly talentName: string; readonly treeId: string; readonly rank: number; readonly description: string; } export function runtimeActiveTalentRankEffects( ranks: RuntimeTalentRanks, classId?: ClassId, ): readonly RuntimeActiveTalentRankEffect[] { const active: RuntimeActiveTalentRankEffect[] = []; for (const [talentId, storedRank] of Object.entries(ranks)) { const node = runtimeTalentNodeById(talentId); const tree = node ? treeById[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))); if (rank < 1) continue; active.push({ talentId: node.id, talentName: node.name, treeId: node.treeId, rank, description: node.rankDescriptions[rank - 1] ?? node.rankDescriptions.at(-1) ?? node.name, }); } return active; } export function runtimeTalentPointsForLevel(level: number): number { return Math.max(0, clampPlayerLevel(level) - 9); } function coaBudget(classId: CoaClassId, level: number) { const definition = snapshot.coaBudgetsByClass[classId]; if (!definition) throw new Error(`Talent catalog for ${classId} was used before it was loaded.`); const normalizedLevel = Math.min(snapshot.maximumCoaLevel, Math.max(1, clampPlayerLevel(level))); const grantedPoints = Math.max(0, normalizedLevel - 9); return { ability: Math.min(definition.maximumAbility, Math.ceil(grantedPoints / 2)), talent: Math.min(definition.maximumTalent, Math.floor(grantedPoints / 2)), }; } function nodeRankCost(node: RuntimeTalentNode, rank: number) { const safeRank = Math.min(node.maxRank, Math.max(0, Math.trunc(rank))); return { ability: safeRank * (node.abilityEssenceCost ?? 0), talent: safeRank * (node.talentEssenceCost ?? 0), }; } function spentCoaEssence(ranks: RuntimeTalentRanks, classId: CoaClassId, treeId?: string) { let ability = 0; let talent = 0; for (const [nodeId, rank] of Object.entries(ranks)) { const node = runtimeTalentNodeById(nodeId); const tree = node ? treeById[node.treeId] : null; if (!node || !tree || tree.classId !== classId || node.system !== "coa" || (treeId && tree.id !== treeId)) continue; const cost = nodeRankCost(node, rank); ability += cost.ability; talent += cost.talent; } return { ability, talent }; } function unspentCoaEssence(level: number, ranks: RuntimeTalentRanks, classId: CoaClassId) { const budget = coaBudget(classId, level); const spent = spentCoaEssence(ranks, classId); return { ability: Math.max(0, budget.ability - spent.ability), talent: Math.max(0, budget.talent - spent.talent), }; } function spentTalentPoints(ranks: RuntimeTalentRanks, classId?: ClassId): number { return Object.entries(ranks).reduce((total, [nodeId, rank]) => { const node = runtimeTalentNodeById(nodeId); const tree = node ? treeById[node.treeId] : null; if (!node || !tree || (classId && tree.classId !== classId)) return total; const safeRank = Math.min(node.maxRank, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); if (node.system === "coa") { const cost = nodeRankCost(node, safeRank); return total + cost.ability + cost.talent; } return total + safeRank; }, 0); } function spentTalentPointsInTree(ranks: RuntimeTalentRanks, treeId: string): number { return nodesForTree(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); } function unspentTalentPoints(level: number, ranks: RuntimeTalentRanks, classId?: ClassId): number { if (classId && isCoaClassId(classId)) { const essence = unspentCoaEssence(level, ranks, classId); return essence.ability + essence.talent; } return Math.max(0, runtimeTalentPointsForLevel(level) - spentTalentPoints(ranks, classId)); } function allocationFailure( ranks: RuntimeTalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationFailure | null { const node = runtimeTalentNodeById(nodeId); if (!node) return "unknown-talent"; const tree = treeById[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"; } else { 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 = runtimeTalentNodeById(required.talentId); if (!prerequisite || (ranks[prerequisite.id] ?? 0) < required.requiredRank) return "prerequisite"; } return null; } export function runtimeTalentAllocationFailure( ranks: RuntimeTalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationFailure | null { return allocationFailure(ranks, classId, nodeId, level); } export function runtimeSpentTalentPointsInTree( ranks: RuntimeTalentRanks, treeId: string, ): number { return spentTalentPointsInTree(ranks, treeId); } export function runtimeUnspentTalentPoints( level: number, ranks: RuntimeTalentRanks, classId?: ClassId, ): number { return unspentTalentPoints(level, ranks, classId); } export function runtimeUnspentCoaEssence( level: number, ranks: RuntimeTalentRanks, classId: CoaClassId, ): { readonly ability: number; readonly talent: number } { return unspentCoaEssence(level, ranks, classId); } export function runtimeNormalizeTalentRanks( classId: ClassId, level: number, ranks: RuntimeTalentRanks, ): RuntimeTalentRanks { let normalized: Record = {}; const requested = new Map(); for (const tree of treesForClass(classId)) { for (const node of nodesForTree(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 treesForClass(classId)) { for (const node of nodesForTree(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 runtimeAllocateTalentPoint( ranks: RuntimeTalentRanks, classId: ClassId, nodeId: string, level: number, ): TalentAllocationResult { const normalized = runtimeNormalizeTalentRanks(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 runtimeResetTalentRanks(ranks: RuntimeTalentRanks = {}, classId?: ClassId): RuntimeTalentRanks { if (!classId) return {}; return Object.fromEntries(Object.entries(ranks).filter(([nodeId]) => { const node = runtimeTalentNodeById(nodeId); return !node || treeById[node.treeId]?.classId !== classId; })); } if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") { await preloadRuntimeTalentCatalog(); }