import { useEffect, useMemo, useState, type CSSProperties } from "react"; import type { ClassId } from "../app/characterCatalog"; import { runtimeTalentAllocationFailure } from "../game/talentRuntimeCatalog"; import { talentUiDescriptionForRank, type TalentUiNode, } from "../game/talentUiCatalog"; import type { TalentAllocationFailure, TalentRanks, } from "../game/talentCatalog"; import { useGameStore } from "../game/store"; import { useMenuController, type MenuAction, type MenuDirection } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; export interface TalentTreeView { id: string; name: string; description: string; points: number; color: string; sigil: string; iconUrl?: string; backgroundUrl: string; nodes: readonly TalentUiNode[]; } export interface TalentsPanelProps { readOnly?: boolean; trees: readonly TalentTreeView[]; ranks: TalentRanks; classId: ClassId; playerLevel: number; availablePoints: number; abilityEssence?: number; talentEssence?: number; onInvest: (nodeId: string) => void; onReset: () => void; open?: boolean; onClose?: () => void; } interface TalentNodeState { rank: number; failure: TalentAllocationFailure | null; } const nodeControlId = (nodeId: string) => `talent-node-${nodeId}`; const tabControlId = (treeId: string) => `talent-tab-${treeId}`; function TalentImage({ src, fallback }: { src?: string; fallback: string }) { const [failed, setFailed] = useState(false); return src && !failed ? setFailed(true)} /> : ; } function nearestNode( nodes: readonly TalentUiNode[], source: TalentUiNode, direction: MenuDirection, ): TalentUiNode | null { const vertical = direction === "up" || direction === "down"; const candidates = nodes.filter((candidate) => { if (direction === "up") return candidate.row < source.row; if (direction === "down") return candidate.row > source.row; if (direction === "left") return candidate.column < source.column; return candidate.column > source.column; }); return candidates.sort((left, right) => { const primaryLeft = vertical ? Math.abs(left.row - source.row) : Math.abs(left.column - source.column); const primaryRight = vertical ? Math.abs(right.row - source.row) : Math.abs(right.column - source.column); const crossLeft = vertical ? Math.abs(left.column - source.column) : Math.abs(left.row - source.row); const crossRight = vertical ? Math.abs(right.column - source.column) : Math.abs(right.row - source.row); return crossLeft * 8 + primaryLeft - (crossRight * 8 + primaryRight) || left.row - right.row || left.column - right.column; })[0] ?? null; } function failureText( failure: TalentAllocationFailure | null, node: TalentUiNode, tree: TalentTreeView, prerequisite: TalentUiNode | null, ): string { if (!failure) return "Ready to learn"; if (failure === "max-rank") return "Maximum rank learned"; if (failure === "no-points") return "Earn another talent point to continue"; if (failure === "tier-locked") return `${(node.row - 1) * 5} points in ${tree.name} are required`; if (failure === "level-locked") return `Requires level ${node.requiredLevel ?? "a higher level"}`; if (failure === "ability-essence") return "Not enough Ability Essence"; if (failure === "talent-essence") return "Not enough Talent Essence"; if (failure === "point-gate") { return `${node.requiredClassPoints ?? 0} Ability Essence and ${node.requiredSpecPoints ?? 0} Talent Essence must be invested in ${tree.name}`; } if (failure === "prerequisite") return `${prerequisite?.name ?? "The prerequisite talent"} needs its required rank`; if (failure === "wrong-class") return "This talent belongs to another class"; return "This talent is unavailable"; } function TalentsDialog({ trees, ranks, classId, playerLevel, availablePoints, abilityEssence, talentEssence, onInvest, onReset, onClose, readOnly = false, }: Omit & { onClose: () => void }) { const [activeTreeId, setActiveTreeId] = useState(() => trees[0]?.id ?? ""); const [detailNodeId, setDetailNodeId] = useState(() => trees[0]?.nodes[0]?.id ?? ""); const spent = trees.reduce((total, tree) => total + tree.points, 0); const allNodes = useMemo(() => trees.flatMap((tree) => tree.nodes), [trees]); const activeTree = trees.find((tree) => tree.id === activeTreeId) ?? trees[0] ?? null; const activeNodes = activeTree?.nodes ?? []; const isCoa = Boolean(trees.some((tree) => tree.nodes.some((node) => node.system === "coa"))); const nodeById = useMemo( () => new Map(allNodes.map((node) => [node.id, node] as const)), [allNodes], ); const treeByNodeId = useMemo(() => new Map( trees.flatMap((tree) => tree.nodes.map((node) => [node.id, tree] as const)), ), [trees]); const nodeStateById = useMemo(() => new Map(allNodes.map((node) => { const rank = Math.max(0, Math.min(node.maxRank, Math.trunc(ranks[node.id] ?? 0))); const tree = treeByNodeId.get(node.id); const rawFailure = runtimeTalentAllocationFailure(ranks, classId, node.id, playerLevel); const failure: TalentAllocationFailure | null = rank >= node.maxRank ? "max-rank" : node.system !== "coa" && tree && tree.points < (node.row - 1) * 5 ? "tier-locked" : node.prerequisites.some((required) => (ranks[required.talentId] ?? 0) < required.requiredRank) ? "prerequisite" : rawFailure; return [node.id, { rank, failure }] as const; })), [allNodes, classId, nodeById, playerLevel, ranks, treeByNodeId]); useEffect(() => { if (!trees.some((tree) => tree.id === activeTreeId)) setActiveTreeId(trees[0]?.id ?? ""); }, [activeTreeId, trees]); useEffect(() => { if (!activeNodes.some((node) => node.id === detailNodeId)) setDetailNodeId(activeNodes[0]?.id ?? ""); }, [activeNodes, detailNodeId]); const actions = useMemo(() => { const tabActions = trees.map((tree, index) => ({ id: tabControlId(tree.id), run: () => setActiveTreeId(tree.id), neighbors: { left: tabControlId(trees[(index - 1 + trees.length) % trees.length]?.id ?? tree.id), right: tabControlId(trees[(index + 1) % trees.length]?.id ?? tree.id), down: tree.id === activeTreeId && activeNodes[0] ? nodeControlId(activeNodes[0].id) : tabControlId(activeTreeId), }, })); const nodeActions = activeNodes.map((node) => { const neighbor = (direction: MenuDirection) => nearestNode(activeNodes, node, direction); const up = neighbor("up"); const down = neighbor("down"); const left = neighbor("left"); const right = neighbor("right"); const state = nodeStateById.get(node.id); return { id: nodeControlId(node.id), run: () => { setDetailNodeId(node.id); if (!readOnly && !state?.failure) onInvest(node.id); }, neighbors: { up: up ? nodeControlId(up.id) : tabControlId(activeTreeId), down: down ? nodeControlId(down.id) : spent > 0 ? "talent-reset" : nodeControlId(node.id), left: nodeControlId(left?.id ?? node.id), right: nodeControlId(right?.id ?? node.id), }, }; }); return [ ...tabActions, ...nodeActions, { id: "talent-reset", run: onReset, enabled: !readOnly && spent > 0 }, ]; }, [activeNodes, activeTreeId, nodeStateById, onInvest, onReset, spent, trees]); const controller = useMenuController(actions, { columns: 4, initialId: activeNodes[0] ? nodeControlId(activeNodes[0].id) : undefined, onBack: onClose, }); useEffect(() => { if (controller.selectedId.startsWith("talent-tab-")) { const selectedTreeId = controller.selectedId.slice("talent-tab-".length); if (trees.some((tree) => tree.id === selectedTreeId)) setActiveTreeId(selectedTreeId); return; } if (controller.selectedId.startsWith("talent-node-")) { const selectedNodeId = controller.selectedId.slice("talent-node-".length); const selectedTree = treeByNodeId.get(selectedNodeId); if (selectedTree) { setActiveTreeId(selectedTree.id); setDetailNodeId(selectedNodeId); } } }, [controller.selectedId, treeByNodeId, trees]); const detailNode = nodeById.get(detailNodeId) ?? activeNodes[0] ?? null; const detailTree = detailNode ? treeByNodeId.get(detailNode.id) ?? activeTree : activeTree; const detailState: TalentNodeState | null = detailNode ? nodeStateById.get(detailNode.id) ?? null : null; const prerequisite = detailNode?.prerequisiteId ? nodeById.get(detailNode.prerequisiteId) ?? null : null; const prerequisiteRank = prerequisite ? nodeStateById.get(prerequisite.id)?.rank ?? 0 : 0; const prerequisiteRequiredRank = detailNode?.prerequisiteRank ?? prerequisite?.maxRank ?? 0; const inspectNode = (treeId: string, nodeId: string) => { setActiveTreeId(treeId); setDetailNodeId(nodeId); }; const activateNode = (treeId: string, node: TalentUiNode) => { inspectNode(treeId, node.id); if (!readOnly && !nodeStateById.get(node.id)?.failure) onInvest(node.id); }; return (

Level {playerLevel} specialization

Talents

{isCoa ? ( <> {abilityEssence ?? 0}Ability Essence {talentEssence ?? 0}Talent Essence ) : ( <>{availablePoints}points available )}

{isCoa ? `Live ${trees.length}-tab Conquest tree. Ability and Talent Essence unlock nodes according to Ascension's exact gates and prerequisites.` : "Your first talent point arrives at level 10. Every five points spent in a tree unlocks its next row."}

{trees.map((tree) => (
setActiveTreeId(tree.id)} >
{tree.name}{tree.description} {tree.points}
node.column)) * 100} ${Math.max(11, ...tree.nodes.map((node) => node.row)) * 100}`} preserveAspectRatio="none" aria-hidden="true" > {tree.nodes.flatMap((node) => { const required = node.prerequisiteId ? nodeById.get(node.prerequisiteId) : null; if (!required) return []; const earned = (nodeStateById.get(required.id)?.rank ?? 0) >= (node.prerequisiteRank ?? required.maxRank); return [ , ]; })} {tree.nodes.map((node) => { const state = nodeStateById.get(node.id) ?? { rank: 0, failure: "unknown-talent" as const }; const learned = state.rank > 0; const maximum = state.rank >= node.maxRank; const locked = state.failure !== null && state.failure !== "no-points" && state.failure !== "ability-essence" && state.failure !== "talent-essence" && state.failure !== "max-rank"; const unavailable = state.failure === "no-points" || state.failure === "ability-essence" || state.failure === "talent-essence"; const label = `${node.name}, rank ${state.rank} of ${node.maxRank}`; return ( inspectNode(tree.id, node.id)} onPointerEnter={() => inspectNode(tree.id, node.id)} onClick={() => activateNode(tree.id, node)} > {state.rank}/{node.maxRank} ); })}
))} {!trees.length &&

No talent trees are available for this class.

}
{spent} points spent Reset talents
D-pad NavigateA Learn / inspectB Back
); } export function TalentsPanel(props: TalentsPanelProps) { const storeOpen = useGameStore((state) => state.overlay === "talents"); const openOverlay = useGameStore((state) => state.openOverlay); const close = props.onClose ?? (() => openOverlay("pause")); if (!(props.open ?? storeOpen)) return null; return ; }