import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { DraggableWindow } from './DraggableWindow'; import { apiFetch } from '../../api/client'; interface Props { id: string; charName: string; zIndex: number; inventoryVersion?: number; equipmentCantrips?: any; } // ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ── function normalizeItem(raw: any): any { if (!raw) return raw; const v = (val: any) => (val !== undefined && val !== null && val !== -1 && val !== -1.0) ? val : undefined; const iv = raw.IntValues || {}; return { item_id: raw.item_id ?? raw.Id ?? 0, name: raw.name ?? raw.Name ?? (raw.StringValues?.['1']) ?? 'Unknown', icon: raw.icon ?? raw.Icon ?? 0, object_class: raw.object_class ?? raw.ObjectClass ?? 0, current_wielded_location: raw.current_wielded_location ?? v(raw.CurrentWieldedLocation) ?? v(Number(iv['10'])) ?? 0, container_id: raw.container_id ?? raw.ContainerId ?? 0, items_capacity: raw.items_capacity ?? v(raw.ItemsCapacity) ?? v(Number(iv['6'])) ?? raw.enhanced_properties?.ItemSlots_Decal ?? undefined, value: raw.value ?? v(raw.Value) ?? v(Number(iv['19'])) ?? 0, burden: raw.burden ?? v(raw.Burden) ?? v(Number(iv['5'])) ?? 0, armor_level: raw.armor_level ?? v(raw.ArmorLevel), max_damage: raw.max_damage ?? v(raw.MaxDamage), material: raw.material ?? raw.material_name ?? raw.Material ?? undefined, item_set: raw.item_set ?? raw.ItemSet ?? undefined, imbue: raw.imbue ?? raw.Imbue ?? undefined, tinks: raw.tinks ?? v(raw.Tinks), workmanship: raw.workmanship ?? v(raw.Workmanship), equip_skill: raw.equip_skill ?? raw.equip_skill_name ?? raw.EquipSkill ?? undefined, wield_level: raw.wield_level ?? v(raw.WieldLevel), skill_level: raw.skill_level ?? v(raw.SkillLevel), lore_requirement: raw.lore_requirement ?? v(raw.LoreRequirement), attack_bonus: raw.attack_bonus ?? v(raw.AttackBonus), melee_defense_bonus: raw.melee_defense_bonus ?? v(raw.MeleeDefenseBonus), magic_defense_bonus: raw.magic_defense_bonus ?? v(raw.MagicDBonus), damage_bonus: raw.damage_bonus ?? v(raw.DamageBonus), damage_rating: raw.damage_rating ?? v(raw.DamRating), crit_rating: raw.crit_rating ?? v(raw.CritRating), heal_boost_rating: raw.heal_boost_rating ?? v(raw.HealBoostRating), current_mana: raw.current_mana ?? v(Number(iv['218103815'])) ?? undefined, max_mana: raw.max_mana ?? v(Number(iv['218103814'])) ?? undefined, spellcraft: raw.spellcraft ?? undefined, damage_range: raw.damage_range ?? undefined, damage_type: raw.damage_type ?? undefined, speed_text: raw.speed_text ?? undefined, mana_display: raw.mana_display ?? undefined, spells: raw.spells ?? undefined, icon_overlay_id: raw.icon_overlay_id ?? v(Number(iv['218103849'])) ?? undefined, icon_underlay_id: raw.icon_underlay_id ?? v(Number(iv['218103850'])) ?? undefined, _raw: raw, }; } // ── Icon helpers ── function iconHex(raw: number): string { if (!raw || raw <= 0) return '06000133'; return (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } // ── Equipment slots ── const EQUIP_SLOTS: Record = { 32768:{name:'Neck',row:1,col:1},1:{name:'Head',row:1,col:3},268435456:{name:'Sigil',row:1,col:5},536870912:{name:'Sigil',row:1,col:6},1073741824:{name:'Sigil',row:1,col:7}, 67108864:{name:'Trinket',row:2,col:1},2048:{name:'U.Arm',row:2,col:2},512:{name:'Chest',row:2,col:3},134217728:{name:'Cloak',row:2,col:7}, 65536:{name:'Brace L',row:3,col:1},4096:{name:'L.Arm',row:3,col:2},1024:{name:'Abdomen',row:3,col:3},8192:{name:'U.Leg',row:3,col:4},131072:{name:'Brace R',row:3,col:5},2:{name:'Shirt',row:3,col:7}, 262144:{name:'Ring L',row:4,col:1},32:{name:'Hands',row:4,col:2},16384:{name:'L.Leg',row:4,col:4},524288:{name:'Ring R',row:4,col:5},4:{name:'Pants',row:4,col:7}, 256:{name:'Feet',row:5,col:4}, 2097152:{name:'Shield',row:6,col:1},1048576:{name:'Melee',row:6,col:3},4194304:{name:'Missile',row:6,col:3},16777216:{name:'Held',row:6,col:3},33554432:{name:'2H',row:6,col:3},8388608:{name:'Ammo',row:6,col:7}, }; // Slot colors matching v1 const SLOT_COLORS: Record = {}; const purpleSlots = [32768,67108864,65536,131072,262144,524288]; const blueSlots = [1,512,2048,1024,4096,8192,16384,32,256]; const tealSlots = [2,4,134217728,268435456,536870912,1073741824]; const darkblueSlots = [2097152,1048576,4194304,16777216,33554432,8388608]; // Map slot keys to colors (() => { const seen = new Set(); Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => { const k = `${def.row}-${def.col}`; const m = parseInt(maskStr); if (!seen.has(k)) { seen.add(k); if (purpleSlots.includes(m)) SLOT_COLORS[k] = '#3a2555'; else if (blueSlots.includes(m)) SLOT_COLORS[k] = '#1e2e55'; else if (tealSlots.includes(m)) SLOT_COLORS[k] = '#1e3e3e'; else if (darkblueSlots.includes(m)) SLOT_COLORS[k] = '#142040'; else SLOT_COLORS[k] = '#2a2a2a'; } }); })(); const gold = '#af7a30'; function ItemIcon({ item, size = 36 }: { item: any; size?: number }) { const s: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: size, height: size, border: 'none', background: 'transparent', imageRendering: 'pixelated' }; const underlay = item.icon_underlay_id && item.icon_underlay_id > 100 ? `/icons/${iconHex(item.icon_underlay_id)}.png` : null; const overlay = item.icon_overlay_id && item.icon_overlay_id > 100 ? `/icons/${iconHex(item.icon_overlay_id)}.png` : null; return (
{underlay && { (e.target as HTMLImageElement).style.display = 'none'; }} />} {item.name} { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} /> {overlay && { (e.target as HTMLImageElement).style.display = 'none'; }} />}
); } function ItemTooltip({ item, x, y }: { item: any; x: number; y: number }) { const isV = (val: any) => val !== undefined && val !== null && val !== -1 && val !== -1.0; const fmt = (n: number) => n.toLocaleString(); const pct = (v: number) => `${((v - 1) * 100).toFixed(1)}%`; return (
{item.name}
Value: {fmt(item.value)} · Burden: {item.burden}
{item.workmanship &&
Workmanship: {item.workmanship}
} {item.material &&
Material: {item.material}
} {isV(item.armor_level) &&
Armor Level: {item.armor_level}
} {isV(item.max_damage) &&
Max Damage: {item.max_damage}
} {item.damage_range &&
Damage: {item.damage_range}{item.damage_type ? `, ${item.damage_type}` : ''}
} {isV(item.attack_bonus) && item.attack_bonus !== 1 &&
Attack: +{pct(item.attack_bonus)}
} {isV(item.melee_defense_bonus) && item.melee_defense_bonus !== 1 &&
Melee Def: +{pct(item.melee_defense_bonus)}
} {isV(item.magic_defense_bonus) && item.magic_defense_bonus !== 1 &&
Magic Def: +{pct(item.magic_defense_bonus)}
} {item.equip_skill &&
Skill: {item.equip_skill}
} {isV(item.wield_level) &&
Wield Level: {item.wield_level}
} {isV(item.lore_requirement) &&
Lore: {item.lore_requirement}
} {item.imbue &&
Imbue: {item.imbue}
} {item.item_set &&
Set: {item.item_set}
} {isV(item.tinks) &&
Tinks: {item.tinks}
} {isV(item.damage_rating) &&
Damage Rating: {item.damage_rating}
} {isV(item.crit_rating) &&
Crit Rating: {item.crit_rating}
} {isV(item.heal_boost_rating) &&
Heal Boost: {item.heal_boost_rating}
} {item.spellcraft &&
Spellcraft: {item.spellcraft}
} {isV(item.current_mana) && isV(item.max_mana) &&
Mana: {item.current_mana} / {item.max_mana}
} {item.spells?.spells?.length > 0 &&
Spells: {item.spells.spells.map((s: any) => s.name).join(', ')}
}
); } function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: { iconSrc: string; isActive: boolean; fillPct: number; label: string; onClick: () => void; }) { const fillColor = fillPct > 90 ? '#b7432c' : fillPct > 70 ? '#d8a431' : '#00ff00'; return (
{isActive && }
{ (e.target as HTMLImageElement).src = '/icons/06001080.png'; }} />
0 ? 2 : 0 }} />
); } export const InventoryWindow: React.FC = ({ id, charName, zIndex, inventoryVersion, equipmentCantrips }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [activePack, setActivePack] = useState(null); const [tooltip, setTooltip] = useState<{ item: any; x: number; y: number } | null>(null); const [charStats, setCharStats] = useState(null); const debounceRef = useRef(0); const initialLoadDone = useRef(false); // Initial fetch useEffect(() => { setLoading(true); Promise.all([ apiFetch(`/inventory/${encodeURIComponent(charName)}?limit=1000`).catch(() => ({ items: [] })), apiFetch(`/character-stats/${encodeURIComponent(charName)}`).catch(() => null), ]).then(([inv, stats]) => { setItems((inv.items ?? []).map(normalizeItem)); setCharStats(stats); initialLoadDone.current = true; }).finally(() => setLoading(false)); }, [charName]); // Debounced re-fetch on inventory_delta (no loading flash) useEffect(() => { if (!initialLoadDone.current || !inventoryVersion) return; clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { apiFetch(`/inventory/${encodeURIComponent(charName)}?limit=1000&_t=${Date.now()}`) .then(inv => setItems((inv.items ?? []).map(normalizeItem))) .catch(() => {}); }, 2000); // 2s debounce — batch rapid deltas return () => clearTimeout(debounceRef.current); }, [charName, inventoryVersion]); const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => { if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY }); else setTooltip(null); }, []); const slotPositions = useMemo(() => { const seen = new Set(); const slots: Array<{ key: string; row: number; col: number; mask: number; name: string }> = []; Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => { const k = `${def.row}-${def.col}`; if (!seen.has(k)) { seen.add(k); slots.push({ key: k, ...def, mask: parseInt(maskStr) }); } }); return slots; }, []); const { equippedMap, containers, packItems } = useMemo(() => { const equippedMap = new Map(); const containers: any[] = []; const containerIds = new Set(); const packItems = new Map(); items.forEach(item => { if (item.object_class === 10) { containers.push(item); containerIds.add(item.item_id); } }); containers.sort((a: any, b: any) => (a.item_id >>> 0) - (b.item_id >>> 0)); // Find body container ID (worn items share a container_id that isn't a pack) let bodyContainerId: number | null = null; items.forEach(item => { if (item.current_wielded_location > 0 && item.container_id && !containerIds.has(item.container_id)) { bodyContainerId = item.container_id; } }); items.forEach(item => { if (containerIds.has(item.item_id)) return; const wielded = item.current_wielded_location; if (wielded > 0) { const isArmor = item.object_class === 2; if (isArmor) { // Armor: ALL matching slots Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => { if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) { const key = `${def.row}-${def.col}`; if (!equippedMap.has(key)) equippedMap.set(key, item); } }); } else { // Non-armor: exact match first, then first bit overlap let placed = false; if (EQUIP_SLOTS[wielded]) { const def = EQUIP_SLOTS[wielded]; const key = `${def.row}-${def.col}`; if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; } } if (!placed) { for (const [maskStr, def] of Object.entries(EQUIP_SLOTS)) { if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) { const key = `${def.row}-${def.col}`; if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; break; } } } } } } else { let cid = item.container_id || 0; if (bodyContainerId && cid === bodyContainerId) cid = 0; if (!packItems.has(cid)) packItems.set(cid, []); packItems.get(cid)!.push(item); } }); return { equippedMap, containers, packItems }; }, [items]); // Main backpack: key 0, OR the largest non-container group if bodyContainerId wasn't detected let mainItems = packItems.get(0) ?? []; let mainPackKey: number = 0; if (mainItems.length === 0) { // bodyContainerId wasn't detected — find the biggest group that isn't a container let biggest = 0; for (const [cid, items] of packItems.entries()) { if (!containers.some((c: any) => c.item_id === cid) && items.length > biggest) { biggest = items.length; mainPackKey = cid; } } mainItems = packItems.get(mainPackKey) ?? []; } const activeItems = activePack !== null ? (packItems.get(activePack) ?? []) : mainItems; // Burden const burdenUnits = charStats?.burden_units ?? charStats?.stats_data?.burden_units ?? 0; const encumbranceCap = charStats?.encumbrance_capacity ?? charStats?.stats_data?.encumbrance_capacity ?? 0; const burdenPct = encumbranceCap > 0 ? Math.min(200, (burdenUnits / encumbranceCap) * 100) : 0; const burdenColor = burdenPct > 150 ? '#b7432c' : burdenPct > 100 ? '#d8a431' : '#2e8b57'; if (loading) { return
Loading inventory...
; } return (
{/* LEFT: Equipment + Items */}
{slotPositions.map(slot => { const item = equippedMap.get(slot.key); const slotBg = SLOT_COLORS[slot.key] ?? '#2a2a2a'; return (
item && handleHover(item, e)} onMouseMove={e => item && handleHover(item, e)} onMouseLeave={() => handleHover(null)}> {item ? : }
); })}
Contents of {activePack !== null ? (containers.find((c: any) => c.item_id === activePack)?.name ?? 'Pack') : 'Backpack'}
{activeItems.map((item: any, i: number) => (
handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
))} {Array.from({ length: Math.max(0, 24 - activeItems.length) }).map((_, i) => (
))}
{/* SIDEBAR: Burden + Packs */}
{encumbranceCap > 0 ? `${Math.floor(burdenPct)}%` : 'Burden'}
0 ? `${burdenUnits.toLocaleString()} / ${encumbranceCap.toLocaleString()}` : `Burden: ${items.reduce((s: number, i: any) => s + (i.burden ?? 0), 0).toLocaleString()}`}>
0 ? Math.min(100, (mainItems.length / 102) * 100) : 0} label={`Backpack (${mainItems.length}/102)`} onClick={() => setActivePack(null)} /> {containers.map((c: any) => { const cid = c.item_id; // Count items directly from normalized items array instead of relying on packItems map const childCount = items.filter((i: any) => i.container_id === cid && i.item_id !== cid).length; const cap = c.items_capacity || 24; const pct = cap > 0 ? Math.min(100, (childCount / cap) * 100) : 0; return setActivePack(cid)} />; })}
{/* RIGHT: Mana panel */}
Mana
{(() => { // Merge real cantrip state data if available const cantripItems = equipmentCantrips?.items ?? []; const cantripMap: Map = new Map(cantripItems.map((c: any) => [c.item_id, c])); const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0; const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0; return Array.from(equippedMap.values()) .map((item: any) => { const cantrip = cantripMap.get(item.item_id); const curMana = cantrip?.current_mana ?? item.current_mana ?? 0; const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0; const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null; const liveRemaining = rawRemaining != null ? Math.max(0, rawRemaining - elapsed) : null; const state = cantrip?.state ?? (curMana > 0 ? 'active' : 'not_active'); return { ...item, current_mana: curMana, max_mana: maxMana, liveRemaining, manaState: state }; }) .filter((i: any) => i.current_mana > 0 || i.max_mana > 0) .sort((a: any, b: any) => (a.liveRemaining ?? 999999) - (b.liveRemaining ?? 999999)) .map((item: any, i: number) => { const stateColor = item.manaState === 'active' ? '#4c4' : item.manaState === 'not_active' ? '#c44' : '#da8'; return (
handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
{item.name}
{item.current_mana}/{item.max_mana}
{item.liveRemaining != null ? formatSeconds(item.liveRemaining) : ''}
); }); })()} {Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && (
No mana items equipped
)}
{tooltip && } ); }; function formatSeconds(totalSeconds: number): string { if (totalSeconds <= 0) return '0h00m'; const s = Math.floor(totalSeconds); const hours = Math.floor(s / 3600); const minutes = Math.floor((s % 3600) / 60); return `${hours}h${String(minutes).padStart(2, '0')}m`; }