feat(v2): 13 improvements — functional, visual, UX, backend

Functional:
1. Chat: "▼ New messages below" indicator when scrolled up, click to jump
2. Combat stats: "Clear Session" button (red, with confirm dialog)
3. Inventory: live updates via inventory_delta WS (re-fetches on change)
4. Inventory: real mana time from equipment_cantrip_state WS (live
   countdown with state dot: green=active, red=inactive, yellow=unknown)

Visual:
5. Thin separator line between tool links and sort buttons
6. Selected player row highlighted with darker background (#2a3344)
7. Scroll-to-top button (▲) appears when scrolled past 200px in player list

UX:
8. Double-click player dot on map opens their chat window
9. Right-click player dot shows context menu (Chat/Stats/Inv/Char/Combat/Radar)
10. Ctrl+D keyboard shortcut toggles between map and dashboard views
11. Sound notification on rare drops (880Hz sine beep via Web Audio API)

Backend:
12. Deep-merge lifetime offense/defense per element — accumulates
    total_attacks, failed_attacks, crits, damage per AttackType×Element
    instead of overwriting with latest session data
13. Startup cleanup: deletes stale combat_stats records from before
    the lifetime fix (pre-2026-04-14T09:00Z)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 13:49:40 +02:00
parent 0b64c6ccff
commit 0112c59514
41 changed files with 404 additions and 112 deletions

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useState, useCallback } from 'react';
import { PlayerRow } from './PlayerRow';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
@ -7,18 +7,39 @@ interface Props {
vitals: Map<string, VitalsMessage>;
getColor: (name: string) => string;
onSelect: (name: string) => void;
selectedPlayer?: string | null;
}
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => (
<ul className="ml-player-list">
{players.map(p => (
<PlayerRow
key={p.character_name}
player={p}
vitals={vitals.get(p.character_name) ?? null}
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)}
/>
))}
</ul>
);
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect, selectedPlayer }) => {
const listRef = useRef<HTMLUListElement>(null);
const [showTop, setShowTop] = useState(false);
const handleScroll = useCallback(() => {
if (listRef.current) setShowTop(listRef.current.scrollTop > 200);
}, []);
return (
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<ul className="ml-player-list" ref={listRef} onScroll={handleScroll}>
{players.map(p => (
<PlayerRow
key={p.character_name}
player={p}
vitals={vitals.get(p.character_name) ?? null}
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)}
isSelected={selectedPlayer === p.character_name}
/>
))}
</ul>
{showTop && (
<button onClick={() => { listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }}
style={{ position: 'absolute', bottom: 8, right: 8, width: 28, height: 28, borderRadius: '50%',
background: 'rgba(68,136,255,0.2)', border: '1px solid rgba(68,136,255,0.4)', color: '#6af',
cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
</button>
)}
</div>
);
};

View file

@ -8,9 +8,10 @@ interface Props {
vitals: VitalsMessage | null;
color: string;
onSelect: () => void;
isSelected?: boolean;
}
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect, isSelected }) => {
const { openWindow } = useWindowManager();
const vtState = (p.vt_state || 'idle').toLowerCase();
const isActive = vtState === 'combat' || vtState === 'hunt';
@ -21,7 +22,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
const name = p.character_name;
return (
<li className="ml-player-row" style={{ borderLeftColor: color }}>
<li className={`ml-player-row ${isSelected ? 'ml-player-selected' : ''}`} style={{ borderLeftColor: color }}>
<div className="ml-pr-header" onClick={onSelect}>
<span className="ml-pr-name">{name}</span>
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>