import { useEffect, useRef, useState } from 'react' import { allocateTalent, resetTalents, type CharacterProfile, type Talent, } from '../profile' type Props = { profile: CharacterProfile onBack?: () => void onUpdated: (profile: CharacterProfile) => void embedded?: boolean } export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { const [busyTalentId, setBusyTalentId] = useState(null) const [talentPage, setTalentPage] = useState(0) const [resetting, setResetting] = useState(false) const [message, setMessage] = useState('') const scrollRef = useRef(0) const gameClass = profile.classes.find( (candidate) => candidate.id === profile.character.classId, )! const classPointsSpent = gameClass.talents.reduce( (total, talent) => total + talent.rank, 0, ) const tiers = Array.from( new Set(gameClass.talents.map((talent) => talent.tier)), ).sort((a, b) => a - b) const tierPages = Array.from( { length: Math.ceil(tiers.length / 2) }, (_, index) => tiers.slice(index * 2, index * 2 + 2), ) const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? [] useEffect(() => { window.scrollTo(0, scrollRef.current) }, [profile]) function saveScroll() { scrollRef.current = window.scrollY } function lowerTierPoints(talent: Talent) { return gameClass.talents .filter((candidate) => candidate.tier < talent.tier) .reduce((total, candidate) => total + candidate.rank, 0) } function lockReason(talent: Talent) { if (talent.rank >= talent.maxRank) return 'Maximum rank' const requiredTierPoints = (talent.tier - 1) * 5 if (lowerTierPoints(talent) < requiredTierPoints) { return `Requires ${requiredTierPoints} earlier-tier points` } if (talent.prerequisiteTalentId) { const prerequisite = gameClass.talents.find( (candidate) => candidate.id === talent.prerequisiteTalentId, ) if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) { return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}` } } if (profile.character.talentPoints <= 0) return 'No points available' return '' } async function purchaseRank(talent: Talent) { saveScroll() setBusyTalentId(talent.id) setMessage('') try { const updated = await allocateTalent(talent.id) onUpdated(updated) setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`) } catch (reason) { setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.') } finally { setBusyTalentId(null) } } async function refundTree() { saveScroll() setResetting(true) setMessage('') try { const updated = await resetTalents() onUpdated(updated) setMessage('All points in this talent tree were refunded.') } catch (reason) { setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.') } finally { setResetting(false) } } const content = ( <> {!embedded && (

Character Growth

Talents

)}
{gameClass.name[0]}

{gameClass.name} Tree

Shape Your Healing Style

{profile.character.talentPoints} Available {classPointsSpent} spent in this tree
{visibleTiers.map((tier) => { const requiredPoints = (tier - 1) * 5 return (
Tier {tier} {tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
{gameClass.talents .filter((talent) => talent.tier === tier) .sort((a, b) => a.branch - b.branch) .map((talent) => { const reason = lockReason(talent) const isBusy = busyTalentId === talent.id return (
0 ? 'invested' : ''}`} key={talent.id} style={{ gridColumn: talent.branch }} >
{talent.glyph}
{talent.name} Rank {talent.rank}/{talent.maxRank}

{talent.description}

{Array.from({ length: talent.maxRank }, (_, index) => ( ))}
) })}
) })}
{message || 'Talent changes are saved immediately.'}
) if (embedded) { return
{content}
} return (
{content}
) }