MosswartOverlord/frontend/src/components/windows/CharacterWindow.tsx
Erik d2c30b610b fix(v2): character window now updates live from WebSocket
The CharacterWindow only fetched once from API on mount and never
updated. Now:
- character_stats WS messages are tracked in useLiveData via ref
- Passed through WindowRenderer to CharacterWindow as liveStats prop
- Window uses live WS data when available, falls back to API fetch
- Attributes, skills, vitals base values, properties (augmentations,
  ratings, etc.), allegiance all update in real-time

Also: vitals bars in the character window use live WS vitals data
(health_percentage etc.) for real-time HP/Stamina/Mana display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:02:49 +02:00

282 lines
19 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; vitals?: any; liveStats?: any; }
// Property ID maps — verbatim from v1 script.js lines 1843-1876
const TS_AUGMENTATIONS: Record<number, string> = {
218:'Reinforcement of the Lugians',219:"Bleeargh's Fortitude",220:"Oswald's Enhancement",
221:"Siraluun's Blessing",222:'Enduring Calm',223:'Steadfast Will',
224:"Ciandra's Essence",225:"Yoshi's Essence",226:"Jibril's Essence",
227:"Celdiseth's Essence",228:"Koga's Essence",229:'Shadow of the Seventh Mule',
230:'Might of the Seventh Mule',231:'Clutch of the Miser',232:'Enduring Enchantment',
233:'Critical Protection',234:'Quick Learner',235:"Ciandra's Fortune",
236:'Charmed Smith',237:'Innate Renewal',238:"Archmage's Endurance",
239:'Enhancement of the Blade Turner',240:'Enhancement of the Arrow Turner',
241:'Enhancement of the Mace Turner',242:'Caustic Enhancement',243:'Fierce Impaler',
244:'Iron Skin of the Invincible',245:'Eye of the Remorseless',246:'Hand of the Remorseless',
294:'Master of the Steel Circle',295:'Master of the Focused Eye',
296:'Master of the Five Fold Path',297:'Frenzy of the Slayer',
298:'Iron Skin of the Invincible',299:'Jack of All Trades',
300:'Infused Void Magic',301:'Infused War Magic',
302:'Infused Life Magic',309:'Infused Item Magic',
310:'Infused Creature Magic',326:'Clutch of the Miser',
328:'Enduring Enchantment',
};
const TS_AURAS: Record<number, string> = {
333:'Valor / Destruction',334:'Protection',335:'Glory / Retribution',
336:'Temperance / Hardening',338:'Aetheric Vision',339:'Mana Flow',
340:'Mana Infusion',342:'Purity',343:'Craftsman',344:'Specialization',365:'World',
};
const TS_RATINGS: Record<number, string> = {
370:'Damage',371:'Damage Resistance',372:'Critical',373:'Critical Resistance',
374:'Critical Damage',375:'Critical Damage Resistance',376:'Healing Boost',379:'Vitality',
};
const TS_SOCIETY: Record<number, string> = { 287:'Celestial Hand',288:'Eldrytch Web',289:'Radiant Blood' };
const TS_MASTERIES: Record<number, string> = { 354:'Melee',355:'Ranged',362:'Summoning' };
const TS_MASTERY_NAMES: Record<number, string> = { 1:'Unarmed',2:'Swords',3:'Axes',4:'Maces',5:'Spears',6:'Daggers',7:'Staves',8:'Bows',9:'Crossbows',10:'Thrown',11:'Two-Handed',12:'Void',13:'War',14:'Life' };
const TS_GENERAL: Record<number, string> = { 181:'Chess Rank',192:'Fishing Skill',199:'Total Augmentations',322:'Aetheria Slots',390:'Enlightenment' };
function societyRank(v: number): string {
if (v >= 1001) return 'Master';
if (v >= 301) return 'Lord';
if (v >= 151) return 'Knight';
if (v >= 31) return 'Adept';
return 'Initiate';
}
const gold = '#af7a30';
const navy = '#000022';
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex, vitals, liveStats }) => {
const [fetchedData, setFetchedData] = useState<any>(null);
const [leftTab, setLeftTab] = useState(0);
const [rightTab, setRightTab] = useState(0);
// Initial fetch from API
useEffect(() => {
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`).then(setFetchedData).catch(() => {});
}, [charName]);
// Use live WS data if available (more current), fall back to API fetch
const data = liveStats || fetchedData;
const fmt = (n: any) => n != null ? Number(n).toLocaleString() : '\u2014';
const sd = data?.stats_data || data || {};
const attrs = sd.attributes || {};
const skills = sd.skills || {};
const vit = sd.vitals || {};
const titles = sd.titles || [];
const props = sd.properties || {};
// Group skills
const specSkills = Object.entries(skills).filter(([,v]:any) => v?.training === 'Specialized').sort(([a],[b]) => a.localeCompare(b));
const trainedSkills = Object.entries(skills).filter(([,v]:any) => v?.training === 'Trained').sort(([a],[b]) => a.localeCompare(b));
// Property-based data
const augs = Object.entries(props).filter(([id,v]) => TS_AUGMENTATIONS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_AUGMENTATIONS[parseInt(id)], uses: Number(v) }));
const auras = Object.entries(props).filter(([id,v]) => TS_AURAS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_AURAS[parseInt(id)], uses: Number(v) }));
const ratings = Object.entries(props).filter(([id,v]) => TS_RATINGS[parseInt(id)] && Number(v) > 0).map(([id,v]) => ({ name: TS_RATINGS[parseInt(id)], value: Number(v) }));
const generalRows: Array<{name:string;value:any}> = [];
if (data?.birth) generalRows.push({ name: 'Birth', value: data.birth });
if (data?.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: v }); });
const masteryRows: Array<{name:string;value:string}> = [];
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_MASTERIES[nid]) masteryRows.push({ name: TS_MASTERIES[nid], value: TS_MASTERY_NAMES[Number(v)] || `Unknown (${v})` }); });
const societyRows: Array<{name:string;rank:string;value:number}> = [];
Object.entries(props).forEach(([id,v]) => { const nid = parseInt(id); if (TS_SOCIETY[nid] && Number(v) > 0) societyRows.push({ name: TS_SOCIETY[nid], rank: societyRank(Number(v)), value: Number(v) }); });
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '5px 8px', fontSize: 12, fontWeight: 'bold', color: '#fff', cursor: 'pointer', userSelect: 'none',
borderTop: `2px solid ${active ? gold : navy}`, borderLeft: `2px solid ${active ? gold : navy}`, borderRight: `2px solid ${active ? gold : navy}`,
background: active ? 'rgba(0,100,0,0.4)' : 'transparent',
});
const boxStyle: React.CSSProperties = { background: '#000', border: `2px solid ${gold}`, maxHeight: 400, overflowY: 'auto', overflowX: 'hidden' };
const colNameStyle: React.CSSProperties = { background: '#222', fontWeight: 'bold', fontSize: 12, padding: '2px 6px' };
const cellL: React.CSSProperties = { padding: '2px 6px', background: 'rgba(0,100,0,0.4)', whiteSpace: 'nowrap' };
const cellR: React.CSSProperties = { padding: '2px 6px', background: 'rgba(0,0,100,0.4)', textAlign: 'right', whiteSpace: 'nowrap' };
const cellCreation: React.CSSProperties = { padding: '2px 6px', color: '#ccc' };
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={740} height={600}>
<div style={{ background: navy, color: '#fff', font: '14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', overflowY: 'auto', padding: '10px 15px 15px', flex: 1 }}>
{/* Header */}
<div style={{ marginBottom: 10 }}>
<h1 style={{ margin: '0 0 2px', fontSize: 28, fontWeight: 'bold' }}>
{charName}
<span style={{ fontSize: '200%', color: '#fff27f', float: 'right' }}>{data?.level || ''}</span>
</h1>
<div style={{ fontSize: '85%', color: 'gold' }}>
{[data?.gender, data?.race].filter(Boolean).join(' ') || 'Awaiting character data...'}
</div>
</div>
{/* XP / Luminance */}
<div style={{ fontSize: '85%', margin: '6px 0 10px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<div>Total XP: {fmt(data?.total_xp)}</div>
<div style={{ textAlign: 'right' }}>Unassigned XP: {fmt(data?.unassigned_xp)}</div>
<div>Luminance: {data?.luminance_earned != null ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}` : '\u2014'}</div>
<div style={{ textAlign: 'right' }}>Deaths: {fmt(data?.deaths)}</div>
</div>
{/* Tab row: two side-by-side containers */}
<div style={{ display: 'flex', gap: 13, flexWrap: 'wrap' }}>
{/* Left tabs */}
<div style={{ width: 320 }}>
<div style={{ height: 30, display: 'flex' }}>
{['Attributes', 'Skills', 'Titles'].map((t, i) => (
<div key={t} style={tabStyle(leftTab === i)} onClick={() => setLeftTab(i)}>{t}</div>
))}
</div>
<div style={boxStyle}>
{leftTab === 0 && (
<>
{/* Vitals bars */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 8, borderBottom: `2px solid ${gold}` }}>
{[
{ label: 'Health', pct: vitals?.health_percentage ?? 0, cur: vitals?.health_current, max: vitals?.health_max, bg: '#cc3333' },
{ label: 'Stamina', pct: vitals?.stamina_percentage ?? 0, cur: vitals?.stamina_current, max: vitals?.stamina_max, bg: '#ccaa33' },
{ label: 'Mana', pct: vitals?.mana_percentage ?? 0, cur: vitals?.mana_current, max: vitals?.mana_max, bg: '#3366cc' },
].map(v => (
<div key={v.label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 55, fontSize: 12, color: '#ccc' }}>{v.label}</span>
<div style={{ flex: 1, height: 14, overflow: 'hidden', position: 'relative', border: `1px solid ${gold}` }}>
<div style={{ height: '100%', width: `${v.pct}%`, background: v.bg, transition: 'width 0.5s ease' }} />
</div>
<span style={{ width: 80, textAlign: 'right', fontSize: 12, color: '#ccc' }}>{v.cur ?? '\u2014'} / {v.max ?? '\u2014'}</span>
</div>
))}
</div>
{/* Attributes table */}
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Attribute</td><td style={colNameStyle}>Creation</td><td style={colNameStyle}>Base</td></tr></thead>
<tbody>
{['strength','endurance','coordination','quickness','focus','self'].map(a => (
<tr key={a}><td style={cellL}>{a.charAt(0).toUpperCase() + a.slice(1)}</td><td style={cellCreation}>{attrs[a]?.creation ?? '\u2014'}</td><td style={cellR}>{attrs[a]?.base ?? '\u2014'}</td></tr>
))}
</tbody>
</table>
{/* Vitals base table */}
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Vital</td><td style={colNameStyle}>Base</td></tr></thead>
<tbody>
{['health','stamina','mana'].map(v => (
<tr key={v}><td style={cellL}>{v.charAt(0).toUpperCase() + v.slice(1)}</td><td style={cellR}>{vit[v]?.base ?? '\u2014'}</td></tr>
))}
</tbody>
</table>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody><tr><td style={cellL}>Skill Credits</td><td style={cellR}>{fmt(sd.skill_credits)}</td></tr></tbody>
</table>
</>
)}
{leftTab === 1 && (
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Skill</td><td style={colNameStyle}>Level</td></tr></thead>
<tbody>
{specSkills.map(([k, v]: any) => (
<tr key={k}><td style={{ padding: '2px 6px', background: 'linear-gradient(to right, #392067, #392067, black)' }}>{k.replace(/_/g,' ').replace(/\b\w/g, (c:string) => c.toUpperCase())}</td>
<td style={{ ...cellR, background: 'linear-gradient(to right, #392067, #392067, black)' }}>{v.base}</td></tr>
))}
{trainedSkills.map(([k, v]: any) => (
<tr key={k}><td style={{ padding: '2px 6px', background: 'linear-gradient(to right, #0f3c3e, #0f3c3e, black)' }}>{k.replace(/_/g,' ').replace(/\b\w/g, (c:string) => c.toUpperCase())}</td>
<td style={{ ...cellR, background: 'linear-gradient(to right, #0f3c3e, #0f3c3e, black)' }}>{v.base}</td></tr>
))}
{specSkills.length === 0 && trainedSkills.length === 0 && <tr><td colSpan={2} style={{ padding: 10, color: '#666', fontStyle: 'italic', textAlign: 'center' }}>No skill data</td></tr>}
</tbody>
</table>
)}
{leftTab === 2 && (
<div style={{ padding: '6px 10px', fontSize: 13 }}>
{titles.length > 0 ? titles.map((t: string, i: number) => <div key={i} style={{ padding: '1px 0' }}>{t}</div>) :
<div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No titles</div>}
</div>
)}
</div>
</div>
{/* Right tabs */}
<div style={{ width: 320 }}>
<div style={{ height: 30, display: 'flex' }}>
{['Augmentations', 'Ratings', 'Other'].map((t, i) => (
<div key={t} style={tabStyle(rightTab === i)} onClick={() => setRightTab(i)}>{t}</div>
))}
</div>
<div style={boxStyle}>
{rightTab === 0 && (
augs.length || auras.length ? (
<>
{augs.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Augmentations</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Name</td><td style={colNameStyle}>Uses</td></tr></thead>
<tbody>{augs.map(a => <tr key={a.name}><td style={{ padding: '2px 6px' }}>{a.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{a.uses}</td></tr>)}</tbody>
</table></>
)}
{auras.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Auras</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Name</td><td style={colNameStyle}>Uses</td></tr></thead>
<tbody>{auras.map(a => <tr key={a.name}><td style={{ padding: '2px 6px' }}>{a.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{a.uses}</td></tr>)}</tbody>
</table></>
)}
</>
) : <div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No augmentation data</div>
)}
{rightTab === 1 && (
ratings.length > 0 ? (
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<thead><tr><td style={colNameStyle}>Rating</td><td style={colNameStyle}>Value</td></tr></thead>
<tbody>{ratings.map(r => <tr key={r.name}><td style={{ padding: '2px 6px' }}>{r.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{r.value}</td></tr>)}</tbody>
</table>
) : <div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No rating data</div>
)}
{rightTab === 2 && (
<div>
{generalRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>General</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{generalRows.map(r => <tr key={r.name}><td style={{ padding: '2px 6px' }}>{r.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{r.value}</td></tr>)}</tbody>
</table></>
)}
{masteryRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Masteries</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{masteryRows.map(m => <tr key={m.name}><td style={{ padding: '2px 6px' }}>{m.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{m.value}</td></tr>)}</tbody>
</table></>
)}
{societyRows.length > 0 && (
<><div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Society</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>{societyRows.map(s => <tr key={s.name}><td style={{ padding: '2px 6px' }}>{s.name}</td><td style={{ padding: '2px 6px', textAlign: 'right' }}>{s.rank} ({s.value})</td></tr>)}</tbody>
</table></>
)}
{generalRows.length === 0 && masteryRows.length === 0 && societyRows.length === 0 &&
<div style={{ color: '#666', fontStyle: 'italic', textAlign: 'center', padding: 10 }}>No additional data</div>
}
</div>
)}
</div>
</div>
</div>
{/* Allegiance section */}
{data?.allegiance && (
<div style={{ marginTop: 5, border: `2px solid ${gold}`, background: '#000' }}>
<div style={{ background: '#222', padding: '4px 8px', fontWeight: 'bold', fontSize: 13, borderBottom: `1px solid ${gold}` }}>Allegiance</div>
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
<tbody>
{data.allegiance.name && <tr><td style={{ padding: '2px 6px', color: '#ccc', width: 100 }}>Name</td><td style={{ padding: '2px 6px' }}>{data.allegiance.name}</td></tr>}
{data.allegiance.monarch?.name && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Monarch</td><td style={{ padding: '2px 6px' }}>{data.allegiance.monarch.name}</td></tr>}
{data.allegiance.patron?.name && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Patron</td><td style={{ padding: '2px 6px' }}>{data.allegiance.patron.name}</td></tr>}
{data.allegiance.rank != null && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Rank</td><td style={{ padding: '2px 6px' }}>{data.allegiance.rank}</td></tr>}
{data.allegiance.followers != null && <tr><td style={{ padding: '2px 6px', color: '#ccc' }}>Followers</td><td style={{ padding: '2px 6px' }}>{data.allegiance.followers}</td></tr>}
</tbody>
</table>
</div>
)}
</div>
</DraggableWindow>
);
};