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

@ -40,26 +40,34 @@ interface Props {
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const [hasNewBelow, setHasNewBelow] = useState(false);
const historyRef = useRef<string[]>(loadHistory(charName));
const historyIndexRef = useRef(-1); // -1 = not browsing history
const savedInputRef = useRef(''); // preserves current input when browsing history
const historyIndexRef = useRef(-1);
const savedInputRef = useRef('');
const userScrolledRef = useRef(false);
// Auto-scroll only if user is already at the bottom
useEffect(() => {
const el = msgsRef.current;
if (!el) return;
if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight;
setHasNewBelow(false);
} else {
setHasNewBelow(true);
}
}, [messages.length]);
// Track whether user has scrolled up from bottom
const handleScroll = useCallback(() => {
const el = msgsRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
userScrolledRef.current = !atBottom;
if (atBottom) setHasNewBelow(false);
}, []);
const scrollToBottom = useCallback(() => {
const el = msgsRef.current;
if (el) { el.scrollTop = el.scrollHeight; userScrolledRef.current = false; setHasNewBelow(false); }
}, []);
const handleSend = useCallback((e: React.FormEvent) => {
@ -118,6 +126,11 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, so
</div>
))}
</div>
{hasNewBelow && (
<div onClick={scrollToBottom} style={{ padding: '3px 0', textAlign: 'center', fontSize: '0.65rem', color: '#6af', background: '#1a2a3a', cursor: 'pointer', borderTop: '1px solid #334' }}>
New messages below
</div>
)}
<form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." />

View file

@ -100,10 +100,17 @@ export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) =>
return (
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
{/* Toggle */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}>
{/* Toggle + Clear */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333', alignItems: 'center' }}>
<button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button>
<button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button>
<div style={{ flex: 1 }} />
{mode === 'session' && (
<button style={{ fontSize: '0.6rem', padding: '2px 8px', background: 'rgba(204,68,68,0.15)', color: '#c66', border: '1px solid rgba(204,68,68,0.3)', borderRadius: 3, cursor: 'pointer' }}
onClick={() => { if (confirm('Clear current session stats?')) { /* Send clear command via socket if available, or just clear local */ setData((d: any) => d ? { ...d, session: { total_damage_given: 0, total_damage_received: 0, total_kills: 0, total_aetheria_surges: 0, total_cloak_surges: 0, monsters: {} } } : d); } }}>
Clear Session
</button>
)}
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Monster list (left) */}

View file

@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback } 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; inventoryVersion?: number; equipmentCantrips?: any; }
// ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ──
function normalizeItem(raw: any): any {
@ -157,7 +157,7 @@ function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
);
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex, inventoryVersion, equipmentCantrips }) => {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number | null>(null);
@ -175,7 +175,7 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
setItems(rawItems.map(normalizeItem));
setCharStats(stats);
}).finally(() => setLoading(false));
}, [charName]);
}, [charName, inventoryVersion]); // re-fetch when inventory_delta arrives
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY });
@ -352,21 +352,41 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 160 }}>
<div style={{ padding: '4px 8px', fontSize: '0.72rem', fontWeight: 600, color: '#aaa', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '2px 0' }}>
{Array.from(equippedMap.values())
.filter((i: any) => (i.current_mana > 0 || i.max_mana > 0))
.sort((a: any, b: any) => (a.current_mana ?? 999999) - (b.current_mana ?? 999999))
.map((item: any, i: number) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: item.current_mana > 0 ? '#4c4' : '#c44', flexShrink: 0 }} />
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana ?? 0}/{item.max_mana ?? 0}</div>
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
{item.max_mana > 0 ? formatManaTime(item.current_mana ?? 0, item.max_mana ?? 0) : ''}
</div>
</div>
))}
{(() => {
// Merge real cantrip state data if available
const cantripItems = equipmentCantrips?.items ?? [];
const cantripMap: Map<number, any> = new Map(cantripItems.map((c: any) => [c.item_id, c]));
const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0;
const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0;
return Array.from(equippedMap.values())
.map((item: any) => {
const cantrip = cantripMap.get(item.item_id);
const curMana = cantrip?.current_mana ?? item.current_mana ?? 0;
const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0;
const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null;
const liveRemaining = rawRemaining != null ? Math.max(0, rawRemaining - elapsed) : null;
const state = cantrip?.state ?? (curMana > 0 ? 'active' : 'not_active');
return { ...item, current_mana: curMana, max_mana: maxMana, liveRemaining, manaState: state };
})
.filter((i: any) => i.current_mana > 0 || i.max_mana > 0)
.sort((a: any, b: any) => (a.liveRemaining ?? 999999) - (b.liveRemaining ?? 999999))
.map((item: any, i: number) => {
const stateColor = item.manaState === 'active' ? '#4c4' : item.manaState === 'not_active' ? '#c44' : '#da8';
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: stateColor, flexShrink: 0 }} />
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana}/{item.max_mana}</div>
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
{item.liveRemaining != null ? formatSeconds(item.liveRemaining) : ''}
</div>
</div>
);
});
})()}
{Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && (
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>No mana items equipped</div>
)}
@ -378,11 +398,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
);
};
function formatManaTime(current: number, max: number): string {
if (max <= 0 || current <= 0) return '0h00m';
// Rough estimate: ~20 mana per second drain rate for typical items
const seconds = current * 20;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `~${hours}h${String(minutes).padStart(2, '0')}m`;
function formatSeconds(totalSeconds: number): string {
if (totalSeconds <= 0) return '0h00m';
const s = Math.floor(totalSeconds);
const hours = Math.floor(s / 3600);
const minutes = Math.floor((s % 3600) / 60);
return `${hours}h${String(minutes).padStart(2, '0')}m`;
}

View file

@ -15,10 +15,12 @@ interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, socket }) => {
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socket }) => {
const { windows } = useWindowManager();
return (
@ -37,7 +39,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
vitals={characters.get(charName)?.vitals ?? undefined} />;
case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
inventoryVersion={inventoryVersion} equipmentCantrips={equipmentCantrips.get(charName)} />;
case 'radar':
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;