I Want To Heal 2 build v1.0.5 code

This commit is contained in:
Warren H
2026-06-28 13:29:39 -04:00
parent 8abb44ea02
commit 257c34fc8d
53 changed files with 18187 additions and 1199 deletions
+907
View File
@@ -0,0 +1,907 @@
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<EquipSlot>('mainhand')
const selectedItem = getEquippedActionItem(character, selectedSlot)
const stats = useMemo(() => getEquipmentStats(character), [character])
const weapon = getEquippedActionItem(character, 'mainhand')
return (
<section className="action-gear-panel claudecraft-menu">
<article className="action-gear-summary">
<div>
<p className="eyebrow">ClaudeCraft</p>
<h2>Character</h2>
</div>
<div className="claudecraft-character-meta">
<span>{character.classId}</span>
<span>Level {character.level}</span>
<span>{character.copper} Copper</span>
</div>
</article>
<div className="claudecraft-character-layout">
<div className="action-slot-grid claudecraft-paperdoll" aria-label="Equipped gear">
{ACTION_EQUIP_SLOTS.map((slot) => {
const equipped = getEquippedActionItem(character, slot.slot)
return (
<button
className={selectedSlot === slot.slot ? 'selected' : ''}
key={slot.slot}
onClick={() => setSelectedSlot(slot.slot)}
type="button"
>
<span>{slot.glyph}</span>
<strong>{slot.label}</strong>
<small>{equipped?.name ?? 'Empty'}</small>
</button>
)
})}
</div>
<aside className="action-inventory-panel claudecraft-character-sheet">
<header>
<div>
<p className="eyebrow">Stats</p>
<h2>{character.name}</h2>
</div>
{onOpenInventory && (
<button className="back-button" onClick={onOpenInventory} type="button">Bags</button>
)}
{onOpenTalents && (
<button className="back-button" onClick={onOpenTalents} type="button">Talents</button>
)}
</header>
<dl className="action-stat-compare claudecraft-stat-grid">
<StatLine label="Armor" value={stats.armor} />
<StatLine label="Stamina" value={stats.sta} />
<StatLine label="Strength" value={stats.str} />
<StatLine label="Agility" value={stats.agi} />
<StatLine label="Intellect" value={stats.int} />
<StatLine label="Spirit" value={stats.spi} />
<StatLine label="Weapon" value={weapon?.weapon ? `${weapon.weapon.min}-${weapon.weapon.max}` : 'None'} />
<StatLine label="Speed" value={weapon?.weapon ? weapon.weapon.speed.toFixed(1) : '-'} />
</dl>
<GearDetail item={selectedItem} slot={selectedSlot} />
</aside>
</div>
</section>
)
}
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<Record<string, string>>({})
const [talentTab, setTalentTab] = useState<'class' | string>('class')
const [selectedTalentId, setSelectedTalentId] = useState<string | null>(null)
const canEdit = Boolean(onCharacterChange)
if (!talents) {
return (
<section className="action-gear-panel claudecraft-menu">
<article className="action-gear-summary action-talent-summary">
<div className="claudecraft-character-meta">
<span>No trees</span>
</div>
{onBack && (
<button className="back-button" onClick={onBack} type="button">Back</button>
)}
</article>
</section>
)
}
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 (
<section className="action-gear-panel claudecraft-menu action-talents-menu">
<article className="action-gear-summary action-talent-summary">
<div className="claudecraft-character-meta">
<span>{character.name}</span>
<span>Level {character.level}</span>
<span className="talent-available-pill">Available: {points.available} / {points.total}</span>
<span>Spent: {points.spent}</span>
</div>
<div className="arena-pause-actions action-talent-actions">
{onBack && (
<button className="back-button" onClick={onBack} type="button">Back</button>
)}
<button
className="back-button"
disabled={!canEdit || points.spent <= 0}
onClick={() => onCharacterChange?.(respecActionTalents(character))}
type="button"
>
Respec
</button>
</div>
</article>
<div className="claudecraft-talent-tabs" role="tablist" aria-label="Talent trees">
<button
aria-selected={talentTab === 'class'}
className={talentTab === 'class' ? 'active' : ''}
onClick={() => {
setTalentTab('class')
setSelectedTalentId(null)
}}
role="tab"
type="button"
>
<span>Class</span>
<b>{treeSpent('class')}</b>
</button>
{talents.specs.map((spec) => (
<button
aria-selected={talentTab === spec.id}
className={talentTab === spec.id ? 'active' : ''}
key={spec.id}
onClick={() => {
setTalentTab(spec.id)
setSelectedTalentId(null)
if (character.talents.spec !== spec.id) {
onCharacterChange?.(chooseActionTalentSpec(character, spec.id))
}
}}
role="tab"
type="button"
>
<span>{spec.name}</span>
<b>{treeSpent('spec', spec.id)}</b>
</button>
))}
</div>
{talentTab !== 'class' && selectedSpec && (
<div className="claudecraft-talent-mastery">
<strong>Mastery: {selectedSpec.mastery.name}</strong>
<span>{selectedSpec.mastery.description}</span>
</div>
)}
<div className="claudecraft-talent-body" role="tabpanel">
<TalentTreeCanvas
allocation={character.talents}
canEdit={canEdit}
classId={character.classId}
choices={selectedChoices}
nodes={visibleNodes}
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
onSpend={spendPoint}
points={points}
selectedId={selectedTalent?.id ?? null}
selectedSpec={selectedSpec?.id ?? null}
onSelect={(node) => setSelectedTalentId(node.id)}
/>
<TalentDetailPanel
allocation={character.talents}
canEdit={canEdit}
classId={character.classId}
choices={selectedChoices}
onChoice={(nodeId, choiceId) => setSelectedChoices((current) => ({ ...current, [nodeId]: choiceId }))}
onSpend={spendPoint}
node={selectedTalent}
nodes={visibleNodes}
points={points}
selectedSpec={selectedSpec?.id ?? null}
/>
</div>
</section>
)
}
export function ActionInventoryMenu({
character,
onEquip,
}: {
character: ActionCharacter
onEquip: (itemId: string) => void
}) {
const [filter, setFilter] = useState<InventoryFilter>('all')
const [query, setQuery] = useState('')
const [tooltipItemId, setTooltipItemId] = useState<string | null>(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 (
<section className="action-gear-panel claudecraft-menu">
<article className="action-gear-summary">
<div>
<p className="eyebrow">ClaudeCraft</p>
<h2>Inventory</h2>
</div>
<div className="claudecraft-character-meta">
<span>{entries.length} Slots</span>
<span>{getInventoryCount(entries)} Items</span>
<span>{character.copper} Copper</span>
</div>
</article>
<div className="claudecraft-bag-layout">
<section className="action-inventory-panel claudecraft-bag-panel">
<header>
<div>
<p className="eyebrow">Bags</p>
<h2>{getFilterLabel(filter)}</h2>
</div>
<input
aria-label="Search bags"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
value={query}
/>
</header>
<div className="claudecraft-bag-filters" aria-label="Bag filters">
{(['all', 'weapon', 'armor', 'other'] as InventoryFilter[]).map((nextFilter) => (
<button
aria-pressed={filter === nextFilter}
key={nextFilter}
onClick={() => {
setFilter(nextFilter)
setTooltipItemId(null)
}}
type="button"
>
{getFilterLabel(nextFilter)}
</button>
))}
</div>
<div className="action-inventory-list claudecraft-bag-list" onMouseLeave={() => setTooltipItemId(null)}>
{filteredEntries.length === 0 ? (
<p>Bags empty.</p>
) : (
filteredEntries.map(({ item, count }) => (
<button
key={item.id}
onBlur={() => setTooltipItemId(null)}
onClick={() => {
if (canEquipInventoryItem(character, item)) onEquip(item.id)
}}
onFocus={() => setTooltipItemId(item.id)}
onMouseEnter={() => setTooltipItemId(item.id)}
type="button"
>
<strong>{item.name}</strong>
<small>{getItemTag(item)}{count > 1 ? ` x${count}` : ''}</small>
</button>
))
)}
</div>
{tooltipItem && (
<InventoryItemTooltip
canEquip={canEquipInventoryItem(character, tooltipItem)}
count={tooltipEntry?.count ?? 1}
equippedItem={tooltipEquippedItem}
item={tooltipItem}
/>
)}
</section>
</div>
</section>
)
}
export function ActionMenuOverlay({
children,
onBack,
onClose,
title,
}: {
children: ReactNode
onBack: () => void
onClose: () => void
title: string
}) {
return (
<div className="arena-pause-backdrop claudecraft-menu-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-menu-overlay-title">
<section className="claudecraft-menu-overlay">
<header>
<div>
<p className="eyebrow">Paused</p>
<h2 id="action-menu-overlay-title">{title}</h2>
</div>
<div className="arena-pause-actions">
<button className="back-button" onClick={onBack} type="button">Back</button>
<button className="primary-button" onClick={onClose} type="button">Resume</button>
</div>
</header>
{children}
</section>
</div>
)
}
function TalentTreeCanvas({
allocation,
canEdit,
classId,
choices,
nodes,
onChoice,
onSelect,
onSpend,
points,
selectedId,
selectedSpec,
}: {
allocation: ActionCharacter['talents']
canEdit: boolean
classId: ActionCharacter['classId']
choices: Record<string, string>
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 <div className="claudecraft-talent-empty">Choose a specialization.</div>
}
return (
<div className="claudecraft-talent-tree-scroll">
<div className="claudecraft-talent-tree" style={{ height, width }}>
<svg className="claudecraft-talent-arrows" height={height} width={width}>
{nodes.flatMap((node) => (node.requires ?? []).map((required) => {
const parent = byId.get(required)
if (!parent) return null
const filled = (allocation.ranks[required] ?? 0) > 0
return (
<line
key={`${required}-${node.id}`}
stroke={filled ? '#f5c843' : '#5a4a22'}
strokeWidth="2"
x1={centerX(parent)}
x2={centerX(node)}
y1={centerY(parent) + nodeSize / 2}
y2={centerY(node) - nodeSize / 2}
/>
)
}))}
</svg>
{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 (
<button
aria-label={`${node.name}, rank ${rank}/${node.maxRank}`}
aria-pressed={rank > 0}
aria-disabled={!canSpend && rank <= 0}
className={`claudecraft-talent-node ${shape} ${state} ${selectedId === node.id ? 'selected' : ''}`}
key={node.id}
onClick={() => {
onSelect(node)
if (node.kind === 'choice') {
if (choiceId) onChoice(node.id, choiceId)
if (rank <= 0 && canSpend) onSpend(node, choiceId)
return
}
if (canSpend) onSpend(node)
}}
onMouseEnter={() => onSelect(node)}
style={{
left: node.col * cellWidth + (cellWidth - nodeSize) / 2,
top: node.row * cellHeight + top,
}}
type="button"
>
<span
className="claudecraft-talent-icon"
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
/>
{(rank > 0 || node.maxRank > 1) && (
<span className="claudecraft-talent-rank">{rank}/{node.maxRank}</span>
)}
</button>
)
})}
</div>
</div>
)
}
function TalentDetailPanel({
allocation,
canEdit,
classId,
choices,
node,
nodes,
onChoice,
onSpend,
points,
selectedSpec,
}: {
allocation: ActionCharacter['talents']
canEdit: boolean
classId: ActionCharacter['classId']
choices: Record<string, string>
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 <aside className="claudecraft-talent-detail"><span>Select a talent.</span></aside>
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 (
<aside className="claudecraft-talent-detail">
<header>
<span
className="claudecraft-talent-detail-icon"
style={{ backgroundImage: `url(${getTalentIconUrl(classId, selectedChoice ?? node)})` }}
/>
<div>
<strong>{node.name}</strong>
<small>Rank {rank}/{node.maxRank}</small>
</div>
</header>
<p>{node.description}</p>
{node.requires?.length ? <small>Requires: {node.requires.join(', ')}</small> : null}
{node.pointsGate ? <small>{node.pointsGate} points required above.</small> : null}
{locked && <small className="claudecraft-talent-warning">Locked</small>}
{node.kind === 'choice' && node.choices && (
<div className="claudecraft-talent-choice-list">
{node.choices.map((choice) => {
const selected = choiceId === choice.id
return (
<button
aria-pressed={selected}
className={selected ? 'selected' : ''}
disabled={!canEdit || (rank > 0 && !selected)}
key={choice.id}
onClick={() => {
onChoice(node.id, choice.id)
if (rank <= 0 && canSpend) onSpend(node, choice.id)
}}
type="button"
>
<span style={{ backgroundImage: `url(${getTalentIconUrl(classId, choice)})` }} />
<strong>{choice.name}</strong>
<small>{choice.description}</small>
</button>
)
})}
</div>
)}
{node.kind !== 'choice' && (
<button
className="primary-button"
disabled={!canSpend}
onClick={() => onSpend(node)}
type="button"
>
Add Point
</button>
)}
</aside>
)
}
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'
? `<path ${common} d="M64 20 L100 34 V62 C100 88 82 106 64 116 C46 106 28 88 28 62 V34 Z"/>`
: glyph === 'heart'
? `<path ${common} d="M64 108 C36 82 22 67 22 47 C22 32 32 22 46 22 C54 22 60 26 64 34 C68 26 74 22 82 22 C96 22 106 32 106 47 C106 67 92 82 64 108 Z"/>`
: glyph === 'cross'
? `<path ${common} d="M54 22 H74 V52 H104 V72 H74 V106 H54 V72 H24 V52 H54 Z"/>`
: glyph === 'fist'
? `<path ${common} d="M32 56 H44 V28 H58 V56 H66 V24 H80 V58 H88 V34 H101 V74 C101 94 86 108 64 108 C42 108 28 94 28 74 Z"/>`
: glyph === 'spark'
? `<path ${common} d="M70 12 L46 58 H66 L54 116 L86 50 H66 Z"/>`
: glyph === 'gem'
? `<path ${common} d="M64 18 L104 48 L64 112 L24 48 Z M24 48 H104 M46 48 L64 112 L82 48"/>`
: glyph === 'burst'
? `<path ${common} d="M64 14 L74 48 L108 38 L84 64 L108 90 L74 80 L64 114 L54 80 L20 90 L44 64 L20 38 L54 48 Z"/>`
: `<path ${common} d="M32 28 H96 V100 H32 Z M46 44 H82 M46 64 H82 M46 84 H72"/>`
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><defs><radialGradient id="g" cx="35%" cy="25%" r="80%"><stop stop-color="#ffffff" stop-opacity=".25"/><stop offset=".45" stop-color="${bg}"/><stop offset="1" stop-color="#07090d"/></radialGradient></defs><rect width="128" height="128" rx="18" fill="url(#g)"/><rect x="8" y="8" width="112" height="112" rx="16" fill="none" stroke="#f5c843" stroke-opacity=".35" stroke-width="4"/>${mark}</svg>`
}
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 (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
)
}
function GearDetail({
equippedItem,
item,
onEquip,
slot,
}: {
equippedItem?: ItemDef | null
item: ItemDef | null
onEquip?: () => void
slot?: EquipSlot
}) {
if (!item) {
return (
<section className="action-gear-detail empty">
<p>{slot ? `No item equipped in ${slot}.` : 'Select an item.'}</p>
</section>
)
}
return (
<section className="action-gear-detail">
<header>
<div>
<p className="eyebrow">Selected</p>
<h2>{item.name}</h2>
</div>
<span>{item.quality ?? 'common'}</span>
</header>
<dl className="action-stat-compare">
<div>
<dt>Stats</dt>
<dd>{getItemStatsText(item)}</dd>
</div>
<div>
<dt>Equipped</dt>
<dd>{equippedItem ? getItemStatsText(equippedItem) : 'Empty'}</dd>
</div>
<div>
<dt>Slot</dt>
<dd>{item.slot ?? 'Bag'}</dd>
</div>
<div>
<dt>Class</dt>
<dd>{item.requiredClass?.join(', ') ?? 'Any'}</dd>
</div>
</dl>
{onEquip && (
<button disabled={equippedItem?.id === item.id} onClick={onEquip} type="button">
{equippedItem?.id === item.id ? 'Equipped' : 'Equip'}
</button>
)}
</section>
)
}
function InventoryItemTooltip({
canEquip,
count,
equippedItem,
item,
}: {
canEquip: boolean
count: number
equippedItem?: ItemDef | null
item: ItemDef
}) {
const rows = getItemTooltipRows(item, equippedItem)
return (
<aside className={`claudecraft-action-tooltip claudecraft-item-tooltip quality-${item.quality ?? 'common'}`} role="tooltip">
<div className="tooltip-heading">
<span className="item-tooltip-icon">{getItemIconGlyph(item)}</span>
<div>
<strong>{item.name}</strong>
<span>{formatItemQuality(item)}{count > 1 ? ` x${count}` : ''}</span>
</div>
</div>
<dl>
<div><dt>Slot</dt><dd>{item.slot ? formatEquipSlot(item.slot) : 'Bag'}</dd></div>
<div><dt>Type</dt><dd>{getItemKindLabel(item)}</dd></div>
{item.requiredClass?.length ? <div><dt>Class</dt><dd>{item.requiredClass.join(', ')}</dd></div> : null}
{item.sellValue > 0 ? <div><dt>Sell</dt><dd>{item.sellValue} copper</dd></div> : null}
</dl>
<dl className="item-tooltip-stats">
{rows.length === 0 ? (
<div><dt>Stats</dt><dd>No combat stats</dd></div>
) : rows.map((row) => (
<div className={row.delta === undefined ? '' : row.delta > 0 ? 'better' : row.delta < 0 ? 'worse' : 'same'} key={row.label}>
<dt>{row.label}</dt>
<dd>
<span>{row.value}</span>
{row.delta !== undefined && <em>{formatStatDelta(row.delta)}</em>}
</dd>
</div>
))}
</dl>
{item.slot && (
<p>
{equippedItem
? `Equipped: ${equippedItem.name}`
: `Equipped: empty ${formatEquipSlot(item.slot)}`}
</p>
)}
{item.slot && <em>{canEquip ? 'Click to equip.' : 'Cannot equip.'}</em>}
</aside>
)
}
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<keyof Stats> = ['armor', 'str', 'agi', 'sta', 'int', 'spi']
const STAT_LABELS: Record<keyof Stats, string> = {
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
}