import { useEffect, useMemo, useRef, useState } from 'react' import type { CharacterProfile, DungeonEncounter } from '../profile' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' import { useGameAction, type InputAction } from '../input' type HunterProfileScreenProps = { profile: CharacterProfile onBack: () => void } type BossEntry = { encounter: DungeonEncounter dungeonId: number dungeonName: string contentType: 'dungeon' | 'raid' } type CollectionItem = { key: string glyph: string name: string chance: string quantity: number rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' source: string } type HunterProfileTab = 'stats' | 'collection' type HunterProfileNavEntry = | { kind: 'back'; key: string; row: number; column: number } | { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab } | { kind: 'stat'; key: string; row: number; column: number; index: number } | { kind: 'boss'; key: string; row: number; column: number; bossId: number } | { kind: 'item'; key: string; row: number; column: number; itemKey: string } const HUNTER_PROFILE_DROP_COLUMNS = 6 function bossInitials(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? '') .join('') } function dropChanceLabel(chance: number) { if (chance <= 0) return 'Unavailable' const denominator = Math.round(1 / chance) if (denominator >= 10) return `1 in ${denominator}` return `${Math.round(chance * 100)}%` } function purplePetKey(encounterId: number) { return `purple:${encounterId}` } export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) { const [activeTab, setActiveTab] = useState('stats') const [selectedIndex, setSelectedIndex] = useState(1) const screenRef = useRef(null) const { enabled: dualScreenEnabled } = useDualScreen() const bosses = useMemo(() => profile.dungeons.flatMap((dungeon) => dungeon.encounters .filter((encounter) => encounter.isBoss) .map((encounter) => ({ encounter, dungeonId: dungeon.id, dungeonName: dungeon.name, contentType: dungeon.contentType, })), ), [profile.dungeons]) const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0) const [focusedItemKey, setFocusedItemKey] = useState(null) const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0] const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills]) const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets]) const inventoryQuantities = useMemo( () => new Map(profile.inventory.map((item) => [item.id, item.quantity])), [profile.inventory], ) const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0) const mostKilledBoss = bosses.reduce((best, boss) => { if (!best) return boss return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0) ? boss : best }, null) const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0 const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0 const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0 const collectionItems = useMemo(() => { if (!selectedBoss) return [] return [ { key: `kills:${selectedBoss.encounter.id}`, glyph: 'K', name: 'Boss Kills', chance: 'Defeats', quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0, rarity: 'stat', source: selectedBoss.encounter.enemyName, }, ...selectedBoss.encounter.lootTables.map((drop) => ({ key: `drop:${drop.difficultyId}:${drop.id}`, glyph: drop.glyph, name: drop.name, chance: dropChanceLabel(drop.dropChance), quantity: inventoryQuantities.get(drop.id) ?? 0, rarity: drop.rarity, source: selectedBoss.encounter.enemyName, })), { key: `pet:${selectedBoss.encounter.id}`, glyph: '*', name: `${selectedBoss.encounter.enemyName} Pet`, chance: '1 in 500', quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0, rarity: 'legendary', source: selectedBoss.encounter.enemyName, }, { key: `purple-pet:${selectedBoss.encounter.id}`, glyph: 'P', name: `Purple ${selectedBoss.encounter.enemyName} Pet`, chance: '1 in 500', quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0, rarity: 'epic', source: `${selectedBoss.encounter.enemyName} Roguelike`, }, ] }, [bossKills, bossPets, inventoryQuantities, selectedBoss]) const statTiles = useMemo(() => [ { key: 'stat:total-kills', label: 'Total Boss Kills', value: totalBossKills, }, { key: 'stat:most-killed', label: 'Most Killed Boss', value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None', detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills', }, { key: 'stat:pvp-matches', label: 'PvP Matches', value: matchesPlayed, }, { key: 'stat:pvp-wins', label: 'PvP Wins', value: matchesWon, }, { key: 'stat:pvp-win-rate', label: 'PvP Win Rate', value: `${winRate}%`, }, ], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate]) const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null const navEntries = useMemo(() => { const entries: HunterProfileNavEntry[] = [ { kind: 'back', key: 'back', row: 0, column: 0 }, { kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' }, { kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' }, ] if (activeTab === 'stats') { entries.push(...statTiles.map((tile, index) => ({ kind: 'stat' as const, key: tile.key, row: 1, column: index, index, }))) } else { entries.push( ...bosses.map((boss, index) => ({ kind: 'boss' as const, key: `boss:${boss.encounter.id}`, row: index + 1, column: 0, bossId: boss.encounter.id, })), ...collectionItems.map((item, index) => ({ kind: 'item' as const, key: `item:${item.key}`, row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1, column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1, itemKey: item.key, })), ) } return entries }, [activeTab, bosses, collectionItems, statTiles]) const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1)) const activeEntry = navEntries[activeIndex] const itemDetailState = useMemo(() => { if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null return { mode: 'collection', title: focusedItem.name, subtitle: selectedBoss.encounter.enemyName, summary: `Owned x${focusedItem.quantity}`, items: [ { glyph: focusedItem.glyph, title: 'Drop Rate', meta: focusedItem.chance, detail: focusedItem.source, status: focusedItem.quantity > 0 ? 'Collected' : 'Missing', }, { title: 'Boss Kills', meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`, detail: selectedBoss.dungeonName, }, ], } }, [activeTab, bossKills, focusedItem, selectedBoss]) useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled) function selected(entryKey: string) { return activeEntry?.key === entryKey } function selectKey(entryKey: string) { const index = navEntries.findIndex((entry) => entry.key === entryKey) if (index >= 0) setSelectedIndex(index) } function selectTab(tab: HunterProfileTab) { setActiveTab(tab) setSelectedIndex(tab === 'stats' ? 1 : 2) } function selectBoss(bossId: number) { setSelectedBossId(bossId) setFocusedItemKey(null) } function moveSelection(action: InputAction) { if (!action.startsWith('navigate') || navEntries.length === 0) return setSelectedIndex((current) => { const bounded = Math.min(current, navEntries.length - 1) const active = navEntries[bounded] if (!active) return 0 const candidates = navEntries .map((entry, index) => ({ entry, index })) .filter(({ index }) => index !== bounded) .filter(({ entry }) => { if (action === 'navigateLeft') return entry.column < active.column if (action === 'navigateRight') return entry.column > active.column if (action === 'navigateUp') return entry.row < active.row return entry.row > active.row }) if (candidates.length === 0) return bounded candidates.sort((a, b) => { const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column) const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column) const aSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.entry.row - active.row) : Math.abs(a.entry.column - active.column) const bSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.entry.row - active.row) : Math.abs(b.entry.column - active.column) return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index }) return candidates[0]?.index ?? bounded }) } function openEntry(entry: HunterProfileNavEntry | undefined) { if (!entry) return if (entry.kind === 'back') onBack() else if (entry.kind === 'tab') selectTab(entry.tab) else if (entry.kind === 'boss') selectBoss(entry.bossId) else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey) } useEffect(() => { if (!activeEntry) return screenRef.current ?.querySelector('[data-game-selected="true"]') ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [activeEntry]) useGameAction((action, inputDevice) => { if (inputDevice !== 'controller') return if (action === 'back') { onBack() return } if (action === 'confirm') { openEntry(activeEntry) return } moveSelection(action) }) return (
{activeTab === 'stats' && (
{statTiles.map((tile) => (
selectKey(tile.key)} > {tile.label} {tile.value} {tile.detail && {tile.detail}}
))}
)} {activeTab === 'collection' && (
{bosses.map((boss) => { return ( ) })}
{selectedBoss && (
{collectionItems.map((item) => ( ))}
)}
)}
) }