import { useMemo, useState } from 'react' import type { ReactNode } from 'react' import { ACTION_EQUIP_SLOTS, chooseActionTalentSpec, getEquippedActionItem, getActionTalentPoints, respecActionTalents, spendActionTalentPoint, type ActionCharacter, } from '../actionMode' import { getTalentSpec, talentsFor, type TalentChoiceOption, type TalentEffect, type TalentNode, } from '../claudeCraftTalents' import { canEquipItem } from '../claudeCraftEquipmentRules' import { getClaudeCraftItem, getItemStatsText } from '../claudeCraftItems' import type { EquipSlot, ItemDef, Stats } from '../claudeCraftTypes' type ItemEntry = { count: number item: ItemDef } type InventoryFilter = 'all' | 'weapon' | 'armor' | 'other' export function ActionCharacterMenu({ character, onOpenInventory, onOpenTalents, }: { character: ActionCharacter onOpenInventory?: () => void onOpenTalents?: () => void }) { const [selectedSlot, setSelectedSlot] = useState('mainhand') const selectedItem = getEquippedActionItem(character, selectedSlot) const stats = useMemo(() => getEquipmentStats(character), [character]) const weapon = getEquippedActionItem(character, 'mainhand') return (

ClaudeCraft

Character

{character.classId} Level {character.level} {character.copper} Copper
{ACTION_EQUIP_SLOTS.map((slot) => { const equipped = getEquippedActionItem(character, slot.slot) return ( ) })}
) } export function ActionTalentMenu({ character, onBack, onCharacterChange, }: { character: ActionCharacter onBack?: () => void onCharacterChange?: (character: ActionCharacter) => void }) { const talents = talentsFor(character.classId) const points = getActionTalentPoints(character) const [selectedChoices, setSelectedChoices] = useState>({}) const [talentTab, setTalentTab] = useState<'class' | string>('class') const [selectedTalentId, setSelectedTalentId] = useState(null) const canEdit = Boolean(onCharacterChange) if (!talents) { return (
No trees
{onBack && ( )}
) } const activeSpecId = talentTab === 'class' ? character.talents.spec : talentTab const selectedSpec = getTalentSpec(character.classId, activeSpecId) const classNodes = talents.nodes.filter((node) => node.tree === 'class') const specNodes = talents.nodes.filter((node) => node.tree === 'spec' && node.specId === activeSpecId) const visibleNodes = talentTab === 'class' ? classNodes : specNodes const selectedTalent = visibleNodes.find((node) => node.id === selectedTalentId) ?? visibleNodes[0] ?? null const treeSpent = (tree: 'class' | 'spec', specId = character.talents.spec) => talents.nodes .filter((node) => node.tree === tree && (tree === 'class' || node.specId === specId)) .reduce((sum, node) => sum + (character.talents.ranks[node.id] ?? 0), 0) const spendPoint = (node: TalentNode, choiceId?: string) => { if (!canEdit) return const result = spendActionTalentPoint(character, node.id, choiceId ?? selectedChoices[node.id]) if (result.ok) onCharacterChange?.(result.character) } return (
{character.name} Level {character.level} Available: {points.available} / {points.total} Spent: {points.spent}
{onBack && ( )}
{talents.specs.map((spec) => ( ))}
{talentTab !== 'class' && selectedSpec && (
Mastery: {selectedSpec.mastery.name} {selectedSpec.mastery.description}
)}
setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))} onSpend={spendPoint} points={points} selectedId={selectedTalent?.id ?? null} selectedSpec={selectedSpec?.id ?? null} onSelect={(node) => setSelectedTalentId(node.id)} /> setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))} onSpend={spendPoint} node={selectedTalent} nodes={visibleNodes} points={points} selectedSpec={selectedSpec?.id ?? null} />
) } export function ActionInventoryMenu({ character, onEquip, }: { character: ActionCharacter onEquip: (itemId: string) => void }) { const [filter, setFilter] = useState('all') const [query, setQuery] = useState('') const [tooltipItemId, setTooltipItemId] = useState(null) const entries = useMemo(() => getInventoryEntries(character), [character]) const filteredEntries = useMemo(() => { const normalizedQuery = query.trim().toLowerCase() return entries .filter(({ item }) => { if (filter === 'weapon' && item.kind !== 'weapon') return false if (filter === 'armor' && item.kind !== 'armor') return false if (filter === 'other' && (item.kind === 'weapon' || item.kind === 'armor')) return false if (!normalizedQuery) return true return item.name.toLowerCase().includes(normalizedQuery) }) .sort((a, b) => (getItemPower(b.item) - getItemPower(a.item)) || a.item.name.localeCompare(b.item.name)) }, [entries, filter, query]) const tooltipEntry = filteredEntries.find((entry) => entry.item.id === tooltipItemId) ?? null const tooltipItem = tooltipEntry?.item ?? null const tooltipEquippedItem = tooltipItem?.slot ? getEquippedActionItem(character, tooltipItem.slot) : null return (

ClaudeCraft

Inventory

{entries.length} Slots {getInventoryCount(entries)} Items {character.copper} Copper

Bags

{getFilterLabel(filter)}

setQuery(event.target.value)} placeholder="Search" value={query} />
{(['all', 'weapon', 'armor', 'other'] as InventoryFilter[]).map((nextFilter) => ( ))}
setTooltipItemId(null)}> {filteredEntries.length === 0 ? (

Bags empty.

) : ( filteredEntries.map(({ item, count }) => ( )) )}
{tooltipItem && ( )}
) } export function ActionMenuOverlay({ children, onBack, onClose, title, }: { children: ReactNode onBack: () => void onClose: () => void title: string }) { return (

Paused

{title}

{children}
) } function TalentTreeCanvas({ allocation, canEdit, classId, choices, nodes, onChoice, onSelect, onSpend, points, selectedId, selectedSpec, }: { allocation: ActionCharacter['talents'] canEdit: boolean classId: ActionCharacter['classId'] choices: Record nodes: TalentNode[] onChoice: (nodeId: string, choiceId: string) => void onSelect: (node: TalentNode) => void onSpend: (node: TalentNode, choiceId?: string) => void points: { available: number, spent: number, total: number } selectedId: string | null selectedSpec: string | null }) { const cols = Math.max(1, ...nodes.map((node) => node.col + 1)) const rows = Math.max(1, ...nodes.map((node) => node.row + 1)) const cellWidth = 86 const cellHeight = 70 const nodeSize = 46 const top = 8 const width = cols * cellWidth const height = rows * cellHeight + top const byId = new Map(nodes.map((node) => [node.id, node])) const centerX = (node: TalentNode) => node.col * cellWidth + cellWidth / 2 const centerY = (node: TalentNode) => node.row * cellHeight + top + nodeSize / 2 if (nodes.length === 0) { return
Choose a specialization.
} return (
{nodes.flatMap((node) => (node.requires ?? []).map((required) => { const parent = byId.get(required) if (!parent) return null const filled = (allocation.ranks[required] ?? 0) > 0 return ( ) }))} {nodes.map((node) => { const rank = allocation.ranks[node.id] ?? 0 const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec) const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id const selectedChoice = node.choices?.find((choice) => choice.id === choiceId) const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0 const maxed = rank >= node.maxRank const shape = node.kind === 'active' ? 'square' : node.kind === 'choice' ? 'octagon' : 'circle' const state = locked ? 'locked' : maxed ? 'maxed' : rank > 0 ? 'filled' : canSpend ? 'avail' : 'locked' return ( ) })}
) } function TalentDetailPanel({ allocation, canEdit, classId, choices, node, nodes, onChoice, onSpend, points, selectedSpec, }: { allocation: ActionCharacter['talents'] canEdit: boolean classId: ActionCharacter['classId'] choices: Record node: TalentNode | null nodes: TalentNode[] onChoice: (nodeId: string, choiceId: string) => void onSpend: (node: TalentNode, choiceId?: string) => void points: { available: number, spent: number, total: number } selectedSpec: string | null }) { if (!node) return const rank = allocation.ranks[node.id] ?? 0 const locked = isTalentLocked(node, allocation, nodes, points, selectedSpec) const choiceId = choices[node.id] ?? allocation.choices[node.id] ?? node.choices?.[0]?.id const selectedChoice = node.choices?.find((choice) => choice.id === choiceId) const canSpend = canEdit && !locked && rank < node.maxRank && points.available > 0 return ( ) } function getTalentIconUrl(classId: ActionCharacter['classId'], source: TalentNode | TalentChoiceOption) { const abilityId = getTalentAbilityIconId(source.effect) if (abilityId) return `/ui/skills/${classId}/${abilityId}.png` return getTalentCrestIconUrl(source.effect, 'choices' in source ? 'choice' : 'node') } function getTalentAbilityIconId(effect?: TalentEffect) { return effect?.grant?.ability ?? effect?.ability?.[0]?.ability ?? null } function getTalentCrestIconUrl(effect: TalentEffect | undefined, kind: 'choice' | 'node') { const stat = effect?.stats ? Object.keys(effect.stats)[0] : '' const crest = stat.includes('armor') ? { bg: '#2d3542', fg: '#d8e2ee', glyph: 'shield' } : stat.includes('dodge') || stat === 'agi' ? { bg: '#163b4f', fg: '#9fe4ff', glyph: 'spark' } : stat.includes('hp') || stat === 'sta' ? { bg: '#4f1418', fg: '#ffd6d6', glyph: 'heart' } : stat.includes('ap') || stat === 'str' ? { bg: '#4d2f0c', fg: '#ffd36e', glyph: 'fist' } : effect?.global?.healPct ? { bg: '#254525', fg: '#bcffb5', glyph: 'cross' } : effect?.global?.threatPct ? { bg: '#313641', fg: '#d9e5ff', glyph: 'shield' } : effect?.global ? { bg: '#4a3510', fg: '#ffe28a', glyph: 'burst' } : kind === 'choice' ? { bg: '#2e1c4e', fg: '#f0d2ff', glyph: 'gem' } : { bg: '#252b35', fg: '#dde7ff', glyph: 'rune' } return svgDataUrl(renderTalentCrestSvg(crest.bg, crest.fg, crest.glyph)) } function svgDataUrl(svg: string) { return `data:image/svg+xml;base64,${window.btoa(svg)}` } function renderTalentCrestSvg(bg: string, fg: string, glyph: string) { const common = `fill="${fg}" stroke="#120d04" stroke-width="3" stroke-linejoin="round"` const mark = glyph === 'shield' ? `` : glyph === 'heart' ? `` : glyph === 'cross' ? `` : glyph === 'fist' ? `` : glyph === 'spark' ? `` : glyph === 'gem' ? `` : glyph === 'burst' ? `` : `` return `${mark}` } function isTalentLocked( node: TalentNode, allocation: ActionCharacter['talents'], siblingNodes: TalentNode[], points: { available: number }, selectedSpec: string | null, ) { if (!allocation.spec) return true if (node.tree === 'spec' && node.specId !== selectedSpec) return true for (const required of node.requires ?? []) { if ((allocation.ranks[required] ?? 0) <= 0) return true } const gate = node.pointsGate ?? 0 if (gate > 0) { const spentAbove = siblingNodes .filter((candidate) => candidate.tree === node.tree && candidate.row < node.row) .reduce((sum, candidate) => sum + (allocation.ranks[candidate.id] ?? 0), 0) if (spentAbove < gate) return true } return points.available <= 0 && (allocation.ranks[node.id] ?? 0) < node.maxRank } function StatLine({ label, value }: { label: string, value: number | string }) { return (
{label}
{value}
) } function GearDetail({ equippedItem, item, onEquip, slot, }: { equippedItem?: ItemDef | null item: ItemDef | null onEquip?: () => void slot?: EquipSlot }) { if (!item) { return (

{slot ? `No item equipped in ${slot}.` : 'Select an item.'}

) } return (

Selected

{item.name}

{item.quality ?? 'common'}
Stats
{getItemStatsText(item)}
Equipped
{equippedItem ? getItemStatsText(equippedItem) : 'Empty'}
Slot
{item.slot ?? 'Bag'}
Class
{item.requiredClass?.join(', ') ?? 'Any'}
{onEquip && ( )}
) } function InventoryItemTooltip({ canEquip, count, equippedItem, item, }: { canEquip: boolean count: number equippedItem?: ItemDef | null item: ItemDef }) { const rows = getItemTooltipRows(item, equippedItem) return ( ) } function getInventoryEntries(character: ActionCharacter): ItemEntry[] { return character.inventory .map((slot) => ({ item: getClaudeCraftItem(slot.itemId), count: slot.count })) .filter((entry): entry is ItemEntry => Boolean(entry.item)) } function getInventoryCount(entries: ItemEntry[]) { return entries.reduce((total, entry) => total + entry.count, 0) } type ItemTooltipRow = { delta?: number label: string value: string } const STAT_ORDER: Array = ['armor', 'str', 'agi', 'sta', 'int', 'spi'] const STAT_LABELS: Record = { agi: 'Agility', armor: 'Armor', int: 'Intellect', spi: 'Spirit', sta: 'Stamina', str: 'Strength', } function getItemTooltipRows(item: ItemDef, equippedItem?: ItemDef | null): ItemTooltipRow[] { const rows: ItemTooltipRow[] = [] if (item.weapon) { const equippedWeapon = equippedItem?.weapon const itemAverage = (item.weapon.min + item.weapon.max) / 2 const equippedAverage = equippedWeapon ? (equippedWeapon.min + equippedWeapon.max) / 2 : 0 rows.push({ delta: equippedItem ? itemAverage - equippedAverage : undefined, label: 'Damage', value: `${item.weapon.min}-${item.weapon.max}`, }) rows.push({ delta: equippedWeapon ? equippedWeapon.speed - item.weapon.speed : undefined, label: 'Speed', value: item.weapon.speed.toFixed(1), }) } for (const key of STAT_ORDER) { const value = item.stats?.[key] ?? 0 if (!value) continue const equippedValue = equippedItem?.stats?.[key] ?? 0 rows.push({ delta: equippedItem ? value - equippedValue : undefined, label: STAT_LABELS[key], value: key === 'armor' ? String(value) : `+${value}`, }) } return rows } function formatStatDelta(delta: number) { if (delta === 0) return '+0' return delta > 0 ? `+${formatDeltaNumber(delta)}` : formatDeltaNumber(delta) } function formatDeltaNumber(value: number) { return Number.isInteger(value) ? String(value) : value.toFixed(1) } function getItemIconGlyph(item: ItemDef) { if (item.kind === 'weapon') return '/' if (item.kind === 'armor') return 'H' if (item.kind === 'potion' || item.kind === 'elixir') return '+' return '*' } function formatItemQuality(item: ItemDef) { const quality = item.quality ?? 'common' return quality[0].toUpperCase() + quality.slice(1) } function formatEquipSlot(slot: EquipSlot) { return ACTION_EQUIP_SLOTS.find((entry) => entry.slot === slot)?.label ?? slot } function getItemKindLabel(item: ItemDef) { if (item.kind === 'weapon' && item.weapon?.dagger) return 'Dagger' if (item.kind === 'weapon') return 'Weapon' if (item.kind === 'armor') return item.armorType ? `${item.armorType} armor` : 'Armor' return item.kind } function getEquipmentStats(character: ActionCharacter): Stats { const stats: Stats = { str: 0, agi: 0, sta: 0, int: 0, spi: 0, armor: 0 } for (const slot of ACTION_EQUIP_SLOTS) { const item = getEquippedActionItem(character, slot.slot) if (!item?.stats) continue stats.str += item.stats.str ?? 0 stats.agi += item.stats.agi ?? 0 stats.sta += item.stats.sta ?? 0 stats.int += item.stats.int ?? 0 stats.spi += item.stats.spi ?? 0 stats.armor += item.stats.armor ?? 0 } return stats } function canEquipInventoryItem(character: ActionCharacter, item: ItemDef) { return Boolean(item.slot && (item.kind === 'weapon' || item.kind === 'armor') && canEquipItem(character.classId, item)) } function getFilterLabel(filter: InventoryFilter) { if (filter === 'weapon') return 'Weapons' if (filter === 'armor') return 'Armor' if (filter === 'other') return 'Other' return 'All' } function getItemTag(item: ItemDef) { const quality = item.quality ?? 'common' const slot = item.slot ?? item.kind const stats = getItemStatsText(item) return stats ? `${quality} - ${slot} - ${stats}` : `${quality} - ${slot}` } function getItemPower(item: ItemDef) { const stats = item.stats ?? {} const statScore = (stats.str ?? 0) + (stats.agi ?? 0) + (stats.sta ?? 0) + (stats.int ?? 0) + (stats.spi ?? 0) const armorScore = (stats.armor ?? 0) / 12 const weaponScore = item.weapon ? item.weapon.min + item.weapon.max : 0 return statScore + armorScore + weaponScore }