fix(v2): comprehensive bug fix round — all reported issues

1. Server stats: now shows player count, latency (rounded), uptime hours
2. Rares/Kills counters: fixed API response fields (all_time/total)
3. Chat send: wired socket.send with v1 envelope { player_name, command }
4. Stats button: opens Grafana iframe grid (4 panels, time range selector)
5. Char button: opens character window with attributes/skills/vitals from
   /character-stats/{name} API, structured display with sections
6. Inventory button: full inventory window with equipment table (material,
   set, imbue, AL, dmg, work, tink) + pack contents pill grid + filter
7. Radar button: opens radar window, sends start/stop commands via socket
8. Sidebar links: added Inventory Search, Suitbuilder, Player Debug
9. Color palette: expanded from 30 to 60 distinct colors matching v1
10. Window types properly routed: stats- prefix → Grafana, char- → character
    data, inv- → inventory, radar- → radar with socket commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:31:06 +02:00
parent de7b547349
commit b77450b6eb
19 changed files with 529 additions and 223 deletions

View file

@ -9,14 +9,13 @@ interface CombatStatsResponse {
stats: CombatStatsMessage[];
}
interface CountResponse {
total_rares?: number;
total_kills?: number;
count?: number;
}
// v1 response shapes: /total-rares → { all_time, today }, /total-kills → { total }
interface RaresResponse { all_time: number; today: number; }
interface KillsResponse { total: number; }
export const getLive = () => apiFetch<LiveResponse>('/live');
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
export const getTotalRares = () => apiFetch<CountResponse>('/total-rares');
export const getTotalKills = () => apiFetch<CountResponse>('/total-kills');
export const getTotalRares = () => apiFetch<RaresResponse>('/total-rares');
export const getTotalKills = () => apiFetch<KillsResponse>('/total-kills');
export const getCharacterStats = (name: string) => apiFetch<Record<string, unknown>>(`/character-stats/${encodeURIComponent(name)}`);

View file

@ -57,7 +57,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
showHeatmap={showHeatmap}
showPortals={showPortals}
/>
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} />
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} socket={data.socketRef.current} />
<RareNotification recentRares={data.recentRares} />
</div>
</WindowManagerProvider>

View file

@ -58,8 +58,12 @@ export const Sidebar: React.FC<Props> = ({
<div className="ml-server-status">
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="ml-status-text">Coldeve</span>
{serverHealth?.latency_ms != null && <span className="ml-status-latency">{serverHealth.latency_ms}ms</span>}
<span className="ml-status-text">Coldeve {isOnline ? 'Online' : 'Offline'}</span>
{serverHealth?.player_count != null && <span className="ml-status-detail">👥 {serverHealth.player_count}</span>}
{serverHealth?.latency_ms != null && <span className="ml-status-detail">{Math.round(serverHealth.latency_ms)}ms</span>}
{serverHealth?.uptime_seconds != null && (
<span className="ml-status-detail">Up: {Math.floor(serverHealth.uptime_seconds / 3600)}h</span>
)}
</div>
<div className="ml-counters">
@ -68,6 +72,13 @@ export const Sidebar: React.FC<Props> = ({
<div className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
</div>
{/* Tool links */}
<div className="ml-tool-links">
<a href="/inventory.html" className="ml-tool-link">🔍 Inventory Search</a>
<a href="/suitbuilder.html" className="ml-tool-link">🛡 Suitbuilder</a>
<a href="/debug.html" className="ml-tool-link">🐛 Player Debug</a>
</div>
{/* Map toggles */}
<div className="ml-toggles">
<label className="ml-toggle-label">

View file

@ -49,7 +49,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
<div className="ml-pr-buttons">
<button className="ml-btn accent" onClick={() => openWindow(`chat-${name}`, `Chat: ${name}`, name)}>Chat</button>
<button className="ml-btn accent" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Stats</button>
<button className="ml-btn accent" onClick={() => openWindow(`stats-${name}`, `Stats: ${name}`, name)}>Stats</button>
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>

View file

@ -2,58 +2,88 @@ import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props {
id: string;
charName: string;
zIndex: number;
}
interface Props { id: string; charName: string; zIndex: number; }
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
const [data, setData] = useState<any>(null);
useEffect(() => {
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
.then(setStats).catch(() => {});
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`)
.then(setData).catch(() => {});
}, [charName]);
const session = (stats as any)?.session;
const sd = data?.stats_data;
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
{session ? (
<>
<div style={{ marginBottom: 8 }}>
<strong>Session</strong>: {session.total_kills ?? 0} kills, {(session.total_damage_given ?? 0).toLocaleString()} dmg given
</div>
<div style={{ marginBottom: 8 }}>
<strong>Monsters fought</strong>: {Object.keys(session.monsters ?? {}).filter((k: string) => k !== '__cloak_surges__').length}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#888' }}>
<th style={{ textAlign: 'left', padding: '2px 4px' }}>Monster</th>
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Kills</th>
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Dmg Given</th>
</tr>
</thead>
<tbody>
{Object.values(session.monsters ?? {})
.filter((m: any) => m.name !== '__cloak_surges__')
.sort((a: any, b: any) => (b.damage_given ?? 0) - (a.damage_given ?? 0))
.slice(0, 30)
.map((m: any) => (
<tr key={m.name} style={{ borderBottom: '1px solid #222' }}>
<td style={{ padding: '2px 4px' }}>{m.name}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{m.kill_count}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{(m.damage_given ?? 0).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</>
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={550} height={500}>
<div style={{ padding: 10, fontSize: '0.78rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
{!sd ? (
<div style={{ color: '#666' }}>Loading character data...</div>
) : (
<div style={{ color: '#666' }}>Loading combat data...</div>
<>
{/* Level + XP header */}
<div style={{ display: 'flex', gap: 16, marginBottom: 10, flexWrap: 'wrap' }}>
{data?.level && <span><strong>Level:</strong> {data.level}</span>}
{data?.total_xp != null && <span><strong>Total XP:</strong> {Number(data.total_xp).toLocaleString()}</span>}
{data?.unassigned_xp != null && <span><strong>Unassigned:</strong> {Number(data.unassigned_xp).toLocaleString()}</span>}
{data?.luminance_earned != null && <span><strong>Luminance:</strong> {Number(data.luminance_earned).toLocaleString()}</span>}
{data?.deaths != null && <span><strong>Deaths:</strong> {data.deaths}</span>}
</div>
{/* Attributes */}
{sd.attributes && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Attributes</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
{Object.entries(sd.attributes).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{sd.skills && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Skills</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 12px', fontSize: '0.72rem' }}>
{Object.entries(sd.skills)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
</div>
))}
</div>
</div>
)}
{/* Vitals */}
{sd.vitals && (
<div>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Vitals</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
{Object.entries(sd.vitals).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.current ?? JSON.stringify(v)) : v}</span>
</div>
))}
</div>
</div>
)}
{/* Raw data fallback if no structured sections */}
{!sd.attributes && !sd.skills && !sd.vitals && (
<pre style={{ fontSize: '0.68rem', color: '#888', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(sd, null, 2)}
</pre>
)}
</>
)}
</div>
</DraggableWindow>

View file

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { wsUrl } from '../../api/client';
interface ChatMsg {
text: string;
@ -18,9 +19,10 @@ interface Props {
charName: string;
zIndex: number;
messages: ChatMsg[];
socket: WebSocket | null;
}
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages }) => {
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
@ -28,6 +30,15 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages })
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
}, [messages.length]);
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || !socket || socket.readyState !== WebSocket.OPEN) return;
// v1 envelope: { player_name, command }
socket.send(JSON.stringify({ player_name: charName, command: text }));
setInput('');
};
return (
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
<div className="ml-chat-messages" ref={msgsRef}>
@ -37,7 +48,7 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages })
</div>
))}
</div>
<form className="ml-chat-form" onSubmit={e => { e.preventDefault(); setInput(''); }}>
<form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
</form>
</DraggableWindow>

View file

@ -17,52 +17,108 @@ interface Item {
ItemSet?: string;
Imbue?: string;
EquipSkill?: string;
Mastery?: string;
Tinks?: number;
WieldLevel?: number;
ContainerId?: number;
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
useEffect(() => {
setLoading(true);
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=1000`)
.then(d => setItems(d.items ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, [charName]);
const filtered = filter
? items.filter(i => i.Name?.toLowerCase().includes(filter.toLowerCase()))
: items;
// Separate equipped items (those with equipment-related fields) from pack contents
const equipped = filtered.filter(i =>
(i.ArmorLevel && i.ArmorLevel > 0) || (i.MaxDamage && i.MaxDamage > 0) ||
i.Imbue || i.ItemSet || (i.Tinks && i.Tinks > 0)
);
const other = filtered.filter(i => !equipped.includes(i));
return (
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={620} height={550}>
<div style={{ padding: '4px 8px', borderBottom: '1px solid #333' }}>
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter items..."
style={{ width: '100%', padding: '4px 8px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, outline: 'none', boxSizing: 'border-box' }}
/>
</div>
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.73rem' }}>
{loading ? (
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
) : items.length === 0 ? (
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
</tr>
))}
</tbody>
</table>
<>
{equipped.length > 0 && (
<>
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#88f', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
Equipment &amp; Notable Items ({equipped.length})
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem' }}>
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Item</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Imbue</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Wk</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Tink</th>
</tr>
</thead>
<tbody>
{equipped.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '2px 6px', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>{item.Name}</td>
<td style={{ padding: '2px 4px', color: '#888', fontSize: '0.68rem' }}>{item.Material || ''}</td>
<td style={{ padding: '2px 4px', color: '#9d9', fontSize: '0.68rem' }}>{item.ItemSet || ''}</td>
<td style={{ padding: '2px 4px', color: '#da8', fontSize: '0.68rem' }}>{item.Imbue || ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#f88' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#8af' }}>{item.Tinks && item.Tinks > 0 ? item.Tinks : ''}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{other.length > 0 && (
<>
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#888', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
Pack Contents ({other.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4 }}>
{other.map((item, i) => (
<div
key={i}
style={{ fontSize: '0.65rem', padding: '2px 6px', background: '#252525', borderRadius: 3, color: '#aaa', cursor: 'default' }}
title={`${item.Name}${item.Value ? ` (${item.Value} pyreal)` : ''}`}
>
{item.Name}
</div>
))}
</div>
</>
)}
</>
)}
</div>
</DraggableWindow>

View file

@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface Props {
id: string;
charName: string;
zIndex: number;
socket: WebSocket | null;
nearbyObjects: any[];
}
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, nearbyObjects }) => {
// Send start_radar when window opens, stop_radar when it closes
useEffect(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar start' }));
}
return () => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar stop' }));
}
};
}, [charName, socket]);
const objects = nearbyObjects || [];
return (
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={450} height={400}>
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.73rem' }}>
{objects.length === 0 ? (
<div style={{ padding: 16, color: '#666', textAlign: 'center' }}>
Waiting for nearby objects data...<br/>
<span style={{ fontSize: '0.65rem' }}>The plugin will start sending radar data shortly.</span>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem' }}>
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Name</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Type</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dist</th>
</tr>
</thead>
<tbody>
{objects.map((obj: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '2px 6px' }}>{obj.name}</td>
<td style={{ padding: '2px 4px', color: '#888' }}>{obj.type || obj.object_class || ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{obj.distance ? `${Math.round(obj.distance)}m` : ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DraggableWindow>
);
};

View file

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface Props { id: string; charName: string; zIndex: number; }
const PANELS = [
{ title: 'Kills per Hour', id: 1 },
{ title: 'Memory (MB)', id: 2 },
{ title: 'CPU (%)', id: 3 },
{ title: 'Mem Handles', id: 4 },
];
const TIME_RANGES = [
{ label: '1H', value: 'now-1h' },
{ label: '6H', value: 'now-6h' },
{ label: '24H', value: 'now-24h' },
{ label: '7D', value: 'now-7d' },
];
export const StatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [timeRange, setTimeRange] = useState('now-24h');
const iframeUrl = (panelId: number) =>
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${panelId}&var-character=${encodeURIComponent(charName)}&from=${timeRange}&to=now&theme=light`;
return (
<DraggableWindow id={id} title={`Stats: ${charName}`} zIndex={zIndex} width={750} height={480}>
<div className="ml-stats-controls">
{TIME_RANGES.map(r => (
<button
key={r.value}
className={`ml-stats-range-btn ${timeRange === r.value ? 'active' : ''}`}
onClick={() => setTimeRange(r.value)}
>
{r.label}
</button>
))}
</div>
<div className="ml-stats-grid">
{PANELS.map(p => (
<div key={p.id} className="ml-stats-panel">
<iframe
src={iframeUrl(p.id)}
width="100%"
height="100%"
frameBorder="0"
title={p.title}
/>
</div>
))}
</div>
</DraggableWindow>
);
};

View file

@ -1,16 +1,19 @@
import React from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { ChatWindow } from './ChatWindow';
import { StatsWindow } from './StatsWindow';
import { CharacterWindow } from './CharacterWindow';
import { InventoryWindow } from './InventoryWindow';
import { RadarWindow } from './RadarWindow';
import type { CharacterState } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) => {
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, socket }) => {
const { windows } = useWindowManager();
return (
@ -19,18 +22,26 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) =>
const charName = w.charName ?? '';
if (w.id.startsWith('chat-')) {
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={chatMessages.get(charName) ?? []} />;
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
messages={chatMessages.get(charName) ?? []} socket={socket} />;
}
if (w.id.startsWith('char-') || w.id.startsWith('combat-')) {
if (w.id.startsWith('stats-')) {
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
if (w.id.startsWith('char-')) {
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
if (w.id.startsWith('inv-')) {
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
// Fallback: generic window with placeholder
return (
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
);
if (w.id.startsWith('radar-')) {
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} nearbyObjects={[]} />;
}
if (w.id.startsWith('combat-')) {
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
return null;
})}
</>
);

View file

@ -13,6 +13,7 @@ export interface DashboardState {
totalKills: number;
recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
socketRef: React.RefObject<WebSocket | null>;
}
export function useLiveData(): DashboardState {
@ -63,7 +64,7 @@ export function useLiveData(): DashboardState {
}
}, [updateChar]);
useWebSocket(handleWS);
const socketRef = useWebSocket(handleWS);
// HTTP polls as fallback/initial load
useEffect(() => {
@ -131,8 +132,8 @@ export function useLiveData(): DashboardState {
const fetch = async () => {
try {
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
setTotalRares(rares.total_rares ?? rares.count ?? 0);
setTotalKills(kills.total_kills ?? kills.count ?? 0);
setTotalRares((rares as any).all_time ?? 0);
setTotalKills((kills as any).total ?? 0);
} catch { /* ignore */ }
};
fetch();
@ -140,5 +141,5 @@ export function useLiveData(): DashboardState {
return () => clearInterval(id);
}, []);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages };
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, socketRef };
}

View file

@ -1,11 +1,25 @@
import { useRef, useCallback } from 'react';
// Matches v1 script.js PALETTE — 30 accessible colors
// Matches v1 script.js PALETTE — 60 distinct high-contrast colors
const PALETTE = [
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f',
'#bcbd22','#17becf','#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94',
'#f7b6d2','#c7c7c7','#dbdb8d','#9edae5','#393b79','#637939','#8c6d31','#843c39',
'#7b4173','#5254a3','#6b6ecf','#9c9ede','#d6616b','#ce6dbd',
// Original colorblind-friendly (10)
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd',
'#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
// Extended high-contrast (10)
'#ff4444','#44ff44','#4444ff','#ffff44','#ff44ff',
'#44ffff','#ff8844','#88ff44','#4488ff','#ff4488',
// Darker variants (10)
'#cc3333','#33cc33','#3333cc','#cccc33','#cc33cc',
'#33cccc','#cc6633','#66cc33','#3366cc','#cc3366',
// Brighter variants (10)
'#ff6666','#66ff66','#6666ff','#ffff66','#ff66ff',
'#66ffff','#ffaa66','#aaff66','#66aaff','#ff66aa',
// Additional distinct (10)
'#990099','#009900','#000099','#990000','#009999',
'#999900','#aa5500','#55aa00','#0055aa','#aa0055',
// Light pastels (10)
'#ffaaaa','#aaffaa','#aaaaff','#ffffaa','#ffaaff',
'#aaffff','#ffccaa','#ccffaa','#aaccff','#ffaacc',
];
function hashColor(name: string): string {

View file

@ -4,7 +4,7 @@ import type { WSMessage } from '../types';
type MessageHandler = (msg: WSMessage) => void;
export function useWebSocket(onMessage: MessageHandler) {
export function useWebSocket(onMessage: MessageHandler): React.RefObject<WebSocket | null> {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<number>(0);
const onMessageRef = useRef(onMessage);
@ -41,4 +41,6 @@ export function useWebSocket(onMessage: MessageHandler) {
wsRef.current = null;
};
}, [connect]);
return wsRef;
}

View file

@ -66,8 +66,29 @@
}
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
.ml-status-dot.offline { background: #c44; }
.ml-status-detail { color: #888; font-size: 0.7rem; }
.ml-status-latency { margin-left: auto; color: #888; }
/* ── Tool links ───────────────────────────────────────── */
.ml-tool-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.ml-tool-link {
font-size: 0.68rem;
color: #8ac;
text-decoration: none;
padding: 2px 6px;
background: rgba(68, 136, 255, 0.08);
border: 1px solid rgba(68, 136, 255, 0.15);
border-radius: 3px;
transition: all 0.15s;
}
.ml-tool-link:hover { background: rgba(68, 136, 255, 0.18); color: #adf; }
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ── Aggregate counters ───────────────────────────────── */
@ -483,6 +504,44 @@
flex-direction: column;
}
/* ── Stats window (Grafana iframes) ───────────────────── */
.ml-stats-controls {
display: flex;
gap: 4px;
padding: 6px 10px;
border-bottom: 1px solid #333;
}
.ml-stats-range-btn {
padding: 3px 10px;
font-size: 0.7rem;
background: #2a2a2a;
color: #888;
border: 1px solid #444;
border-radius: 3px;
cursor: pointer;
}
.ml-stats-range-btn.active { background: rgba(68,136,255,0.15); color: #6aadff; border-color: rgba(68,136,255,0.3); }
.ml-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
flex: 1;
}
.ml-stats-panel {
min-height: 200px;
background: #fff;
border-radius: 3px;
overflow: hidden;
}
.ml-stats-panel iframe {
border: none;
}
/* ── Chat window ──────────────────────────────────────── */
.ml-chat-messages {
flex: 1;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" />
<script type="module" crossorigin src="/v2/assets/index-BkJV_2F3.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-B55o-nLL.css">
<script type="module" crossorigin src="/v2/assets/index-Cr_LEFjh.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
</head>
<body>
<div id="root"></div>