fix(v2): inventory — full item normalization + all visual fixes

1. Item normalization: normalizeItem() handles ALL formats:
   - Inventory service (snake_case): current_wielded_location, object_class
   - Plugin raw (PascalCase): CurrentWieldedLocation, ObjectClass
   - Plugin IntValues: IntValues['10'] for wielded, ['5'] for burden
   - Sentinel filtering: -1 values properly excluded

2. Equipment slots: armor (object_class=2) fills ALL matching slots.
   Non-armor uses exact mask match first, then first bit overlap.
   Body container ID detected to separate worn from pack items.

3. Slot colors: per-slot-type backgrounds matching v1:
   purple (#3a2555) for jewelry, blue (#1e2e55) for armor,
   teal (#1e3e3e) for clothing, dark blue (#142040) for weapons

4. Burden: fetches /character-stats/{name} for burden_units and
   encumbrance_capacity. Shows percentage when available, raw burden
   otherwise. Bar fills 0-200% mapped to 0-100% height with
   green/orange/red thresholds.

5. Mana panel: shows equipped items with current/max mana + estimated
   time remaining. State dot green/red. Sorted by mana ascending.

6. Fonts: switched to system font stack (-apple-system etc.) instead
   of Palatino for crisp rendering.

7. Tooltip: proper system font, larger text (13px), structured sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 21:40:32 +02:00
parent 2b1c06a5e0
commit a8078c51ec
3 changed files with 266 additions and 242 deletions

View file

@ -4,54 +4,60 @@ import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
interface Item {
item_id?: number; Id?: number; Name?: string; name?: string;
ObjectClass?: number; object_class?: number;
Icon?: number; icon?: number;
Value?: number; value?: number; Burden?: number; burden?: number;
ArmorLevel?: number; armor_level?: number;
MaxDamage?: number; max_damage?: number;
Workmanship?: number; workmanship?: number;
Material?: string; material?: string; material_name?: string;
ItemSet?: string; item_set?: string;
Imbue?: string; imbue?: string;
EquipSkill?: string; equip_skill?: string; equip_skill_name?: string;
Tinks?: number; tinks?: number;
ContainerId?: number; container_id?: number;
current_wielded_location?: number; CurrentWieldedLocation?: number;
IntValues?: Record<string, number>;
items_capacity?: number; ItemsCapacity?: number;
damage_range?: string; damage_type?: string; speed_text?: string;
attack_bonus?: number; melee_defense_bonus?: number; magic_defense_bonus?: number;
mana_display?: string; spellcraft?: number;
damage_rating?: number; crit_rating?: number; heal_boost_rating?: number;
spells?: 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'])) ?? 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');
}
function getIconUrl(item: Item): string { return `/icons/${iconHex(item.icon ?? item.Icon ?? 0)}.png`; }
function getName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; }
function v(val: number | undefined | null): number | undefined { return (val !== undefined && val !== null && val !== -1) ? val : undefined; }
function overlayUrl(item: Item): string | null {
const id = (item as any).icon_overlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103849'] && Number(iv['218103849']) > 100) return `/icons/${iconHex(Number(iv['218103849']))}.png`;
return null;
}
function underlayUrl(item: Item): string | null {
const id = (item as any).icon_underlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103850'] && Number(iv['218103850']) > 100) return `/icons/${iconHex(Number(iv['218103850']))}.png`;
return null;
}
// Equipment slot map
// ── Equipment slots ──
const EQUIP_SLOTS: Record<number, { name: string; row: number; col: number }> = {
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},
@ -61,50 +67,72 @@ const EQUIP_SLOTS: Record<number, { name: string; row: number; col: number }> =
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<string, string> = {};
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<string>();
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: Item; size?: number }) {
const under = underlayUrl(item);
const over = overlayUrl(item);
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 (
<div style={{ width: size, height: size, position: 'relative' }}>
{under && <img src={under} alt="" style={{ ...s, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<img src={getIconUrl(item)} alt={getName(item)} style={{ ...s, zIndex: 2 }} onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{over && <img src={over} alt="" style={{ ...s, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
{underlay && <img src={underlay} alt="" style={{ ...s, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<img src={`/icons/${iconHex(item.icon)}.png`} alt={item.name} style={{ ...s, zIndex: 2 }} onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{overlay && <img src={overlay} alt="" style={{ ...s, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
</div>
);
}
// Tooltip component that follows mouse
function ItemTooltip({ item, x, y }: { item: Item; x: number; y: number }) {
const isValid = (val: any) => val !== undefined && val !== null && val !== -1 && val !== -1.0;
const enhanced = item as any;
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 (
<div style={{ position: 'fixed', left: x + 12, top: y + 12, background: 'rgba(0,0,0,0.95)', border: '1px solid #555', borderRadius: 4, padding: 10, zIndex: 99999, minWidth: 200, maxWidth: 350, fontSize: '0.78rem', color: '#ddd', pointerEvents: 'none' }}>
<div style={{ color: '#ffcc00', fontWeight: 'bold', marginBottom: 4 }}>{getName(item)}</div>
<div style={{ color: '#aaa', marginBottom: 2 }}>Value: {(item.value ?? item.Value ?? 0).toLocaleString()}</div>
<div style={{ color: '#aaa', marginBottom: 4 }}>Burden: {item.burden ?? item.Burden ?? 0}</div>
{(item.material ?? item.material_name ?? item.Material) && <div style={{ color: '#88ff88' }}>Material: {item.material ?? item.material_name ?? item.Material}</div>}
{isValid(item.armor_level ?? item.ArmorLevel) && <div style={{ color: '#88ff88' }}>Armor Level: {item.armor_level ?? item.ArmorLevel}</div>}
{isValid(item.max_damage ?? item.MaxDamage) && <div style={{ color: '#88ff88' }}>Max Damage: {item.max_damage ?? item.MaxDamage}</div>}
{enhanced.damage_range && <div style={{ color: '#88ff88' }}>Damage: {enhanced.damage_range}{enhanced.damage_type ? `, ${enhanced.damage_type}` : ''}</div>}
{isValid(enhanced.attack_bonus) && enhanced.attack_bonus !== 1 && <div style={{ color: '#88ff88' }}>Attack Bonus: {((enhanced.attack_bonus - 1) * 100).toFixed(1)}%</div>}
{isValid(enhanced.melee_defense_bonus) && enhanced.melee_defense_bonus !== 1 && <div style={{ color: '#88ff88' }}>Melee Defense: {((enhanced.melee_defense_bonus - 1) * 100).toFixed(1)}%</div>}
{(enhanced.equip_skill_name ?? enhanced.equip_skill ?? item.EquipSkill) && <div style={{ color: '#ddd' }}>Skill: {enhanced.equip_skill_name ?? enhanced.equip_skill ?? item.EquipSkill}</div>}
{isValid(enhanced.wield_level) && <div style={{ color: '#ffaa00' }}>Wield Level: {enhanced.wield_level}</div>}
{isValid(enhanced.skill_level) && <div style={{ color: '#ffaa00' }}>Skill Level: {enhanced.skill_level}</div>}
{isValid(enhanced.lore_requirement) && <div style={{ color: '#ffaa00' }}>Lore: {enhanced.lore_requirement}</div>}
{(item.imbue ?? item.Imbue) && <div style={{ color: '#88ff88' }}>Imbue: {item.imbue ?? item.Imbue}</div>}
{(item.item_set ?? item.ItemSet) && <div style={{ color: '#88ff88' }}>Set: {item.item_set ?? item.ItemSet}</div>}
{isValid(item.tinks ?? item.Tinks) && <div style={{ color: '#88ff88' }}>Tinks: {item.tinks ?? item.Tinks}</div>}
{isValid(item.workmanship ?? item.Workmanship) && <div style={{ color: '#88ff88' }}>Workmanship: {item.workmanship ?? item.Workmanship}</div>}
{isValid(enhanced.damage_rating) && <div style={{ color: '#ddd' }}>Damage Rating: {enhanced.damage_rating}</div>}
{isValid(enhanced.crit_rating) && <div style={{ color: '#ddd' }}>Crit Rating: {enhanced.crit_rating}</div>}
{isValid(enhanced.heal_boost_rating) && <div style={{ color: '#ddd' }}>Heal Boost: {enhanced.heal_boost_rating}</div>}
{enhanced.spellcraft && <div style={{ color: '#dda0dd' }}>Spellcraft: {enhanced.spellcraft}</div>}
{enhanced.mana_display && <div style={{ color: '#dda0dd' }}>Mana: {enhanced.mana_display}</div>}
{enhanced.spells?.spells?.length > 0 && <div style={{ color: '#4a90e2', marginTop: 4 }}>Spells: {enhanced.spells.spells.map((s: any) => s.name).join(', ')}</div>}
<div style={{ position: 'fixed', left: x + 14, top: y + 14, background: 'rgba(0,0,0,0.96)', border: '1px solid #555', borderRadius: 4, padding: '8px 12px', zIndex: 99999, minWidth: 200, maxWidth: 340, fontSize: 13, color: '#ddd', pointerEvents: 'none', lineHeight: 1.6, fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif' }}>
<div style={{ color: '#ffcc00', fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>{item.name}</div>
<div style={{ color: '#aaa' }}>Value: {fmt(item.value)} &middot; Burden: {item.burden}</div>
{item.workmanship && <div style={{ color: '#aaa' }}>Workmanship: {item.workmanship}</div>}
{item.material && <div style={{ color: '#88ff88' }}>Material: {item.material}</div>}
{isV(item.armor_level) && <div style={{ color: '#88ff88' }}>Armor Level: {item.armor_level}</div>}
{isV(item.max_damage) && <div style={{ color: '#88ff88' }}>Max Damage: {item.max_damage}</div>}
{item.damage_range && <div style={{ color: '#88ff88' }}>Damage: {item.damage_range}{item.damage_type ? `, ${item.damage_type}` : ''}</div>}
{isV(item.attack_bonus) && item.attack_bonus !== 1 && <div style={{ color: '#88ff88' }}>Attack: +{pct(item.attack_bonus)}</div>}
{isV(item.melee_defense_bonus) && item.melee_defense_bonus !== 1 && <div style={{ color: '#88ff88' }}>Melee Def: +{pct(item.melee_defense_bonus)}</div>}
{isV(item.magic_defense_bonus) && item.magic_defense_bonus !== 1 && <div style={{ color: '#88ff88' }}>Magic Def: +{pct(item.magic_defense_bonus)}</div>}
{item.equip_skill && <div style={{ color: '#ddd' }}>Skill: {item.equip_skill}</div>}
{isV(item.wield_level) && <div style={{ color: '#ffaa00' }}>Wield Level: {item.wield_level}</div>}
{isV(item.lore_requirement) && <div style={{ color: '#ffaa00' }}>Lore: {item.lore_requirement}</div>}
{item.imbue && <div style={{ color: '#88ff88' }}>Imbue: {item.imbue}</div>}
{item.item_set && <div style={{ color: '#88ff88' }}>Set: {item.item_set}</div>}
{isV(item.tinks) && <div style={{ color: '#88ff88' }}>Tinks: {item.tinks}</div>}
{isV(item.damage_rating) && <div>Damage Rating: {item.damage_rating}</div>}
{isV(item.crit_rating) && <div>Crit Rating: {item.crit_rating}</div>}
{isV(item.heal_boost_rating) && <div>Heal Boost: {item.heal_boost_rating}</div>}
{item.spellcraft && <div style={{ color: '#dda0dd' }}>Spellcraft: {item.spellcraft}</div>}
{isV(item.current_mana) && isV(item.max_mana) && <div style={{ color: '#98d7ff' }}>Mana: {item.current_mana} / {item.max_mana}</div>}
{item.spells?.spells?.length > 0 && <div style={{ color: '#4a90e2', marginTop: 4, fontSize: 12 }}>Spells: {item.spells.spells.map((s: any) => s.name).join(', ')}</div>}
</div>
);
}
@ -115,13 +143,12 @@ function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
const fillColor = fillPct > 90 ? '#b7432c' : fillPct > 70 ? '#d8a431' : '#00ff00';
return (
<div onClick={onClick} title={label}
style={{ display: 'flex', alignItems: 'stretch', gap: 2, cursor: 'pointer', flexShrink: 0, marginTop: 2, position: 'relative' }}>
{isActive && <span style={{ position: 'absolute', left: -11, top: 10, color: gold, fontSize: 10 }}></span>}
<div style={{ width: 30, height: 30, border: isActive ? '1px solid #00ff00' : '1px solid transparent', boxShadow: isActive ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
style={{ display: 'flex', alignItems: 'stretch', gap: 2, cursor: 'pointer', flexShrink: 0, marginTop: 3, position: 'relative' }}>
{isActive && <span style={{ position: 'absolute', left: -11, top: 8, color: gold, fontSize: 10 }}></span>}
<div style={{ width: 30, height: 30, border: isActive ? '1px solid #00ff00' : '1px solid #333', boxShadow: isActive ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img src={iconSrc} alt="" style={{ width: 26, height: 26, objectFit: 'contain', imageRendering: 'pixelated' }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06001080.png'; }} />
</div>
{/* Vertical fill bar to the right */}
<div style={{ width: 4, background: '#111', border: '1px solid #333', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${fillPct}%`, background: fillColor, transition: 'height 0.3s' }} />
</div>
@ -130,20 +157,26 @@ function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number | null>(null); // null = main backpack
const [tooltip, setTooltip] = useState<{ item: Item; x: number; y: number } | null>(null);
const [activePack, setActivePack] = useState<number | null>(null);
const [tooltip, setTooltip] = useState<{ item: any; x: number; y: number } | null>(null);
const [charStats, setCharStats] = useState<any>(null);
const [cantripState, setCantripState] = useState<any>(null);
useEffect(() => {
setLoading(true);
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=1000`)
.then(d => setItems(d.items ?? []))
.catch(() => {})
.finally(() => setLoading(false));
Promise.all([
apiFetch<any>(`/inventory/${encodeURIComponent(charName)}?limit=1000`).catch(() => ({ items: [] })),
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`).catch(() => null),
]).then(([inv, stats]) => {
const rawItems = inv.items ?? [];
setItems(rawItems.map(normalizeItem));
setCharStats(stats);
}).finally(() => setLoading(false));
}, [charName]);
const handleItemHover = useCallback((item: Item | null, e?: React.MouseEvent) => {
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY });
else setTooltip(null);
}, []);
@ -159,30 +192,37 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
}, []);
const { equippedMap, containers, packItems } = useMemo(() => {
const equippedMap = new Map<string, Item>();
const containers: Item[] = [];
const equippedMap = new Map<string, any>();
const containers: any[] = [];
const containerIds = new Set<number>();
const packItems = new Map<number, Item[]>();
items.forEach(item => { const oc = item.ObjectClass ?? item.object_class ?? 0; if (oc === 10) { containers.push(item); containerIds.add(item.item_id ?? item.Id ?? 0); } });
// Sort containers by unsigned id for stable order
containers.sort((a, b) => ((a.item_id ?? 0) >>> 0) - ((b.item_id ?? 0) >>> 0));
const packItems = new Map<number, any[]>();
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 (containerIds.has(item.item_id ?? item.Id ?? 0)) return;
const wielded = item.current_wielded_location ?? item.CurrentWieldedLocation ?? (item.IntValues?.['10'] ? Number(item.IntValues['10']) : 0);
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 oc = item.ObjectClass ?? item.object_class ?? 0;
const isArmor = oc === 2;
const isArmor = item.object_class === 2;
if (isArmor) {
// Armor: render in ALL matching slots (multi-slot like chest covers upper arm + chest + abdomen)
// Armor: ALL matching slots
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
const slotMask = parseInt(maskStr);
if ((wielded & slotMask) === slotMask) {
if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) {
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) equippedMap.set(key, item);
}
});
} else {
// Non-armor: exact mask match first, then first bit overlap
// Non-armor: exact match first, then first bit overlap
let placed = false;
if (EQUIP_SLOTS[wielded]) {
const def = EQUIP_SLOTS[wielded];
@ -191,16 +231,16 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
}
if (!placed) {
for (const [maskStr, def] of Object.entries(EQUIP_SLOTS)) {
const slotMask = parseInt(maskStr);
if ((wielded & slotMask) === slotMask) {
if ((wielded & parseInt(maskStr)) === parseInt(maskStr)) {
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) { equippedMap.set(key, item); break; }
if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; break; }
}
}
}
}
} else {
const cid = item.container_id ?? item.ContainerId ?? 0;
let cid = item.container_id || 0;
if (bodyContainerId && cid === bodyContainerId) cid = 0;
if (!packItems.has(cid)) packItems.set(cid, []);
packItems.get(cid)!.push(item);
}
@ -208,14 +248,14 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
return { equippedMap, containers, packItems };
}, [items]);
const mainItems = packItems.get(0) ?? [...packItems.values()].flat().slice(0, 200);
const mainItems = packItems.get(0) ?? [];
const activeItems = activePack !== null ? (packItems.get(activePack) ?? []) : mainItems;
// Burden calculation — sum all item burdens
const totalBurden = items.reduce((s, i) => s + (i.burden ?? i.Burden ?? 0), 0);
// Typical encumbrance capacity is ~15000-30000 burden units
// Without character_stats we estimate; the bar is just visual
const burdenDisplay = totalBurden.toLocaleString();
// 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 <DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={572} height={720}>
@ -225,121 +265,105 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
return (
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={572} height={720}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', background: 'rgba(20,20,20,0.95)', fontFamily: '"Palatino Linotype",serif' }}>
{/* LEFT: Equipment grid + Item grid */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', background: 'rgba(14,14,14,0.96)', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', fontSize: 13 }}>
{/* LEFT: Equipment + Items */}
<div style={{ width: 316, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Equipment grid 7col × 6row */}
<div style={{ position: 'relative', height: 270, minHeight: 270, background: '#0a0a0a', borderBottom: `1px solid ${gold}` }}>
{slotPositions.map(slot => {
const item = equippedMap.get(slot.key);
const slotBg = SLOT_COLORS[slot.key] ?? '#2a2a2a';
return (
<div key={slot.key}
style={{
position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4,
width: 36, height: 36, background: '#5a5a62',
width: 36, height: 36, background: item ? '#5a5a62' : slotBg,
border: item ? '2px solid #00ffff' : '2px outset #6a6a72',
boxShadow: item ? '0 0 5px #00ffff, inset 0 0 5px rgba(0,255,255,0.2)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: item ? 'pointer' : 'default',
}}
onMouseEnter={e => item && handleItemHover(item, e)}
onMouseMove={e => item && handleItemHover(item, e)}
onMouseLeave={() => handleItemHover(null)}>
onMouseEnter={e => item && handleHover(item, e)}
onMouseMove={e => item && handleHover(item, e)}
onMouseLeave={() => handleHover(null)}>
{item ? <ItemIcon item={item} size={32} /> :
<img src="/icons/06000133.png" alt="" style={{ width: 28, height: 28, opacity: 0.15, filter: 'grayscale(100%)', imageRendering: 'pixelated' }} />}
</div>
);
})}
</div>
{/* Contents header */}
<div style={{ padding: '3px 6px', fontSize: 11, color: '#ccc', background: '#111', borderBottom: `1px solid ${gold}` }}>
Contents of {activePack !== null ? (containers.find(c => (c.item_id ?? c.Id) === activePack)?.name ?? containers.find(c => (c.item_id ?? c.Id) === activePack)?.Name ?? 'Pack') : 'Backpack'}
Contents of {activePack !== null ? (containers.find((c: any) => c.item_id === activePack)?.name ?? 'Pack') : 'Backpack'}
</div>
{/* Item grid */}
<div style={{ flex: 1, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(6, 36px)', gridAutoRows: 36, gap: 2, padding: 4, alignContent: 'start' }}>
{activeItems.map((item, i) => (
<div key={item.item_id ?? item.Id ?? i}
{activeItems.map((item: any, i: number) => (
<div key={item.item_id ?? i}
style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3d007a 0%, #1a0033 100%)', border: '1px solid #4a148c', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onMouseEnter={e => handleItemHover(item, e)}
onMouseMove={e => handleItemHover(item, e)}
onMouseLeave={() => handleItemHover(null)}>
onMouseEnter={e => handleHover(item, e)}
onMouseMove={e => handleHover(item, e)}
onMouseLeave={() => handleHover(null)}>
<ItemIcon item={item} size={32} />
</div>
))}
{/* Empty cells to fill 6-col grid */}
{Array.from({ length: Math.max(0, 24 - activeItems.length) }).map((_, i) => (
<div key={`empty-${i}`} style={{ width: 36, height: 36, background: '#0a0a0a', border: '1px solid #222' }} />
<div key={`e${i}`} style={{ width: 36, height: 36, background: '#0a0a0a', border: '1px solid #1a1a1a' }} />
))}
</div>
</div>
{/* SIDEBAR: Burden + Packs */}
<div style={{ width: 38, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '4px 2px', gap: 2, borderLeft: `1px solid ${gold}`, borderRight: `1px solid ${gold}` }}>
{/* Burden label */}
<div style={{ textAlign: 'center', fontSize: 8, color: '#ccc', whiteSpace: 'nowrap', marginBottom: 2 }}>
Burden
<div style={{ width: 42, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '4px 2px', borderLeft: `1px solid ${gold}`, borderRight: `1px solid ${gold}` }}>
<div style={{ textAlign: 'center', fontSize: 8, color: '#ccc', marginBottom: 2 }}>
{encumbranceCap > 0 ? `${Math.floor(burdenPct)}%` : 'Burden'}
</div>
<div style={{ width: 14, height: 40, background: '#111', border: '1px solid #555', position: 'relative', overflow: 'hidden', marginBottom: 4, flexShrink: 0 }}
title={`Burden: ${burdenDisplay}`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%', background: '#2e8b57', transition: 'height 0.3s' }} />
<div style={{ width: 14, height: 40, background: '#111', border: '1px solid #555', position: 'relative', overflow: 'hidden', marginBottom: 6, flexShrink: 0 }}
title={encumbranceCap > 0 ? `${burdenUnits.toLocaleString()} / ${encumbranceCap.toLocaleString()}` : `Burden: ${items.reduce((s: number, i: any) => s + (i.burden ?? 0), 0).toLocaleString()}`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${burdenPct / 2}%`, background: burdenColor, transition: 'height 0.3s' }} />
</div>
{/* Pack list with icons */}
{/* Main backpack */}
<PackIcon
iconSrc="/icons/0600127E.png"
isActive={activePack === null}
<PackIcon iconSrc="/icons/0600127E.png" isActive={activePack === null}
fillPct={Math.min(100, (mainItems.length / 102) * 100)}
label={`Backpack (${mainItems.length}/102)`}
onClick={() => setActivePack(null)}
/>
{/* Sub-packs */}
{containers.map(c => {
const cid = c.item_id ?? c.Id ?? 0;
label={`Backpack (${mainItems.length}/102)`} onClick={() => setActivePack(null)} />
{containers.map((c: any) => {
const cid = c.item_id;
const pItems = packItems.get(cid) ?? [];
const capacity = c.items_capacity ?? c.ItemsCapacity ?? 24;
return (
<PackIcon
key={cid}
iconSrc={`/icons/${iconHex(c.icon ?? c.Icon ?? 0)}.png`}
isActive={activePack === cid}
fillPct={Math.min(100, (pItems.length / capacity) * 100)}
label={`${getName(c)} (${pItems.length}/${capacity})`}
onClick={() => setActivePack(cid)}
/>
);
const cap = c.items_capacity ?? 24;
return <PackIcon key={cid} iconSrc={`/icons/${iconHex(c.icon)}.png`} isActive={activePack === cid}
fillPct={Math.min(100, (pItems.length / cap) * 100)}
label={`${c.name} (${pItems.length}/${cap})`} onClick={() => setActivePack(cid)} />;
})}
</div>
{/* RIGHT: Mana panel */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 162 }}>
<div style={{ padding: '3px 6px', fontSize: 11, fontWeight: 'bold', color: '#ccc', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div>
<div style={{ padding: '2px 4px', fontSize: 9, color: '#888' }}>
{equippedMap.size} equipped items
</div>
<div style={{ flex: 1, overflowY: 'auto', fontSize: 9 }}>
{Array.from(equippedMap.values()).filter(i => (i as any).current_mana > 0 || (i as any).max_mana > 0).map((item, i) => {
const cur = (item as any).current_mana ?? 0;
const max = (item as any).max_mana ?? 0;
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '18px 1fr 14px', gridTemplateRows: 'auto auto', gap: '1px 4px', alignItems: 'center', background: 'rgba(18,24,34,0.9)', border: '1px solid rgba(255,255,255,0.08)', padding: '1px 2px', minHeight: 20, cursor: 'pointer' }}
onMouseEnter={e => handleItemHover(item, e)}
onMouseMove={e => handleItemHover(item, e)}
onMouseLeave={() => handleItemHover(null)}>
<div style={{ flex: 1, overflowY: 'auto' }}>
{Array.from(equippedMap.values())
.filter((i: any) => (i.current_mana > 0 || i.max_mana > 0))
.sort((a: any, b: any) => (a.current_mana ?? 999999) - (b.current_mana ?? 999999))
.map((item: any, i: number) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '18px 1fr 14px', gridTemplateRows: 'auto auto', gap: '0 4px', alignItems: 'center', background: 'rgba(18,24,34,0.9)', border: '1px solid rgba(255,255,255,0.08)', padding: '1px 2px', minHeight: 20, cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
<div style={{ gridRow: '1 / span 2', width: 16, height: 16 }}><ItemIcon item={item} size={16} /></div>
<div style={{ color: '#f2e6c9', fontSize: 9, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getName(item)}</div>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: cur > 0 ? '#76d17f' : '#ff8e6f' }}></div>
<div style={{ color: '#98d7ff', fontSize: 9 }}>{cur} / {max}</div>
<div style={{ color: '#cfe6a0', fontSize: 9, textAlign: 'right' }}></div>
<div style={{ color: '#f2e6c9', fontSize: 9, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: item.current_mana > 0 ? '#76d17f' : '#ff8e6f', justifySelf: 'center' }} />
<div style={{ color: '#98d7ff', fontSize: 9 }}>{item.current_mana ?? 0} / {item.max_mana ?? 0}</div>
<div style={{ color: '#cfe6a0', fontSize: 9, textAlign: 'right' }}>
{item.max_mana > 0 ? formatManaTime(item.current_mana ?? 0, item.max_mana ?? 0) : ''}
</div>
</div>
);
})}
))}
</div>
</div>
</div>
{/* Floating tooltip */}
{tooltip && <ItemTooltip item={tooltip.item} x={tooltip.x} y={tooltip.y} />}
</DraggableWindow>
);
};
function formatManaTime(current: number, max: number): string {
if (max <= 0 || current <= 0) return '0h00m';
// Rough estimate: ~20 mana per second drain rate for typical items
const seconds = current * 20;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `~${hours}h${String(minutes).padStart(2, '0')}m`;
}