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>
This commit is contained in:
Erik 2026-04-14 16:02:49 +02:00
parent a5bd659876
commit d2c30b610b
30 changed files with 115 additions and 54 deletions

View file

@ -66,7 +66,8 @@ export const MapLayout: React.FC<Props> = ({ data }) => {
/>
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
nearbyObjects={data.nearbyObjects} inventoryVersion={data.inventoryVersion}
equipmentCantrips={data.equipmentCantrips} socket={data.socketRef.current} />
equipmentCantrips={data.equipmentCantrips} characterStats={data.characterStats}
socket={data.socketRef.current} />
<RareNotification recentRares={data.recentRares} />
</div>
</WindowManagerProvider>

View file

@ -2,7 +2,7 @@ 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; }
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> = {
@ -49,15 +49,19 @@ function societyRank(v: number): string {
const gold = '#af7a30';
const navy = '#000022';
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex, vitals }) => {
const [data, setData] = useState<any>(null);
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(setData).catch(() => {});
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 || {};

View file

@ -19,10 +19,11 @@ interface Props {
nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socket }) => {
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, characterStats, socket }) => {
const { windows } = useWindowManager();
return (
@ -39,7 +40,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'char':
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
vitals={characters.get(charName)?.vitals ?? undefined} />;
vitals={characters.get(charName)?.vitals ?? undefined}
liveStats={characterStats.get(charName)} />;
case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
inventoryVersion={inventoryVersion} equipmentCantrips={equipmentCantrips.get(charName)} />;

View file

@ -16,6 +16,7 @@ export interface DashboardState {
nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
characterStats: Map<string, any>;
socketRef: React.RefObject<WebSocket | null>;
}
@ -30,6 +31,8 @@ export function useLiveData(): DashboardState {
const [inventoryVersion, setInventoryVersion] = useState(0);
const equipmentCantripRef = useRef(new Map<string, any>());
const [equipCantripVersion, setEquipCantripVersion] = useState(0);
const characterStatsRef = useRef(new Map<string, any>());
const [charStatsVersion, setCharStatsVersion] = useState(0);
const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
const charsRef = useRef(characters);
charsRef.current = characters;
@ -64,6 +67,11 @@ export function useLiveData(): DashboardState {
const d = msg as unknown as { character_name: string };
// Bump inventory version so open inventory windows can re-fetch
setInventoryVersion(v => v + 1);
} else if (msg.type === 'character_stats') {
// Store full character stats for CharacterWindow live updates
const cs = msg as unknown as { character_name: string };
characterStatsRef.current.set(cs.character_name, msg);
setCharStatsVersion(v => v + 1);
} else if (msg.type === 'equipment_cantrip_state') {
const ecs = msg as unknown as { character_name: string; items: any[]; timestamp: string };
equipmentCantripRef.current.set(ecs.character_name, ecs);
@ -182,6 +190,8 @@ export function useLiveData(): DashboardState {
// eslint-disable-next-line react-hooks/exhaustive-deps
const equipmentCantrips = useMemo(() => equipmentCantripRef.current, [equipCantripVersion]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const characterStats = useMemo(() => characterStatsRef.current, [charStatsVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socketRef };
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, characterStats, socketRef };
}