import { useEffect, useMemo, useRef, useState } from 'react' import { allocateTalent, resetTalents, type CharacterProfile, type Talent, } from '../profile' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' type Props = { profile: CharacterProfile onBack?: () => void onUpdated: (profile: CharacterProfile) => void embedded?: boolean } const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const const EFFECT_CLASS_ID = 1 const EFFECTS_PER_PAGE = 8 const EFFECT_SOURCE_LABELS: Record = { mend: 'Mend', radiance: 'Radiance', shield: 'Shield', } function effectSource(effectType: string) { if (effectType.startsWith('mend_')) return 'mend' if (effectType.startsWith('radiance_')) return 'radiance' if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield' return effectType } function effectCapacity(level: number) { return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length } function activeEffects(talents: Talent[]) { return talents.filter((talent) => talent.rank > 0) } export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { const { enabled: dualScreenEnabled } = useDualScreen() const [busyTalentId, setBusyTalentId] = useState(null) const [resetting, setResetting] = useState(false) const [selectedTalentId, setSelectedTalentId] = useState(null) const [effectPage, setEffectPage] = useState(0) const [message, setMessage] = useState('') const scrollRef = useRef(0) const gameClass = profile.classes.find( (candidate) => candidate.id === profile.character.classId, )! const isEffectClass = gameClass.id === EFFECT_CLASS_ID const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0 const selectedEffects = activeEffects(gameClass.talents) const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId) ?? selectedEffects[0] ?? gameClass.talents[0] ?? null const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE)) const visibleTalents = gameClass.talents.slice( effectPage * EFFECTS_PER_PAGE, effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE, ) useEffect(() => { window.scrollTo(0, scrollRef.current) }, [profile]) useEffect(() => { if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return setSelectedTalentId(selectedTalent?.id ?? null) }, [gameClass.talents, selectedTalent?.id, selectedTalentId]) useEffect(() => { setEffectPage((page) => Math.min(page, effectPageCount - 1)) }, [effectPageCount]) function saveScroll() { scrollRef.current = window.scrollY } function lockReason(talent: Talent) { if (!isEffectClass) return 'Coming soon' if (talent.rank > 0) return '' const source = effectSource(talent.effectType) const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source) if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected` if (capacity <= 0) return 'Unlocks at level 5' if (selectedEffects.length >= capacity) { return `Active slots full (${capacity}/${capacity})` } return '' } async function toggleEffect(talent: Talent) { saveScroll() setBusyTalentId(talent.id) setMessage('') try { const updated = await allocateTalent(talent.id) onUpdated(updated) setSelectedTalentId(talent.id) setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`) } catch (reason) { setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.') } finally { setBusyTalentId(null) } } async function clearEffects() { saveScroll() setResetting(true) setMessage('') try { const updated = await resetTalents() onUpdated(updated) setMessage('Spell effects cleared.') } catch (reason) { setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.') } finally { setResetting(false) } } const workshopState = useMemo(() => { if (!isEffectClass) return null return { mode: 'talents', title: 'Spell Effects', subtitle: `${selectedEffects.length}/${capacity} active`, summary: selectedTalent ? `${selectedTalent.name}: ${selectedTalent.description}` : 'Choose effects to modify your spells.', items: gameClass.talents.map((talent) => ({ glyph: talent.glyph, title: talent.name, meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available', detail: talent.description, status: talent.rank > 0 ? 'Selected' : '', })), } }, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent]) useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled) const content = ( <> {!embedded && (

Character Growth

Spell Effects

)}
{gameClass.name[0]}

{gameClass.name} Effects

Modify Your Spells

{selectedEffects.length}/{capacity} Active Slots unlock at levels 5, 10, 15, 20
{!isEffectClass ? (

Spell effects coming soon for {gameClass.name}.

This replacement system starts with the first class.

) : (

Active Slots

{EFFECT_SLOT_LEVELS.map((level, index) => { const effect = selectedEffects[index] const unlocked = profile.character.level >= level return ( ) })}

Effect Pool

Choose and Swap

{selectedEffects.length}/{capacity} active

Selected Effect

{selectedTalent ? ( <> {selectedTalent.name} {selectedTalent.description} ) : ( No effect selected. )}
{selectedTalent && ( )}
{visibleTalents.map((talent) => { const reason = lockReason(talent) const active = talent.rank > 0 const selected = selectedTalent?.id === talent.id const isBusy = busyTalentId === talent.id return ( ) })}
{effectPageCount > 1 && (
{effectPage + 1}/{effectPageCount}
)}
)}
{message || 'Spell effect changes are saved immediately.'}
) if (embedded) { return
{content}
} return (
{content}
) }