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>
282 lines
19 KiB
TypeScript
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>
|
|
);
|
|
};
|