Cleanup: - Removed 109 stale asset files from static/assets/ (was 122, now 13) - Removed static/v2/ entirely (was duplicate of root assets) - Removed dead dashboard code: DashboardView, Layout, GlobalStats, CharacterCard, CharacterGrid, VitalBar, TabContainer, CombatTab, RaresTab, MapTab, InventoryTab, global.css, MapTransformContext - Removed recharts dependency (425KB chunk eliminated) - CSS reduced from 17KB to 10KB - Added deploy-frontend.sh script for one-command build+deploy - Updated CLAUDE.md with combat_stats, share_*, dungeon_map events and React frontend architecture Death alerts (frontend + backend): - Frontend: DeathNotification component with red banner + sawtooth sound when vitae goes from 0 to >0 - Backend: detects vitae transition in vitals handler, sends Discord webhook to #aclog with "☠️ CHARACTER died! (vitae: X%)" - Rate-limited: max 1 Discord alert per character per 5 minutes Idle detection (backend): - Background task runs every 60 seconds - Detects: vt_state "default"/"idle" OR kph=0 while in combat/hunt - Sends Discord webhook: "⚠️ CHARACTER appears idle (state: X, KPH: 0)" - Auto-clears alert when character becomes active again - No duplicate alerts for same idle period Discord integration: - DISCORD_ACLOG_WEBHOOK env var for webhook URL - Used by both death alerts and idle detection - Graceful fallback when not configured Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
71 lines
2.6 KiB
TypeScript
71 lines
2.6 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
||
|
||
interface DeathAlert {
|
||
character_name: string;
|
||
vitae: number;
|
||
timestamp: string;
|
||
}
|
||
|
||
interface Props {
|
||
deathAlerts: DeathAlert[];
|
||
}
|
||
|
||
interface ActiveNotification {
|
||
key: number;
|
||
alert: DeathAlert;
|
||
exiting: boolean;
|
||
}
|
||
|
||
let deathKey = 0;
|
||
|
||
export const DeathNotification: React.FC<Props> = ({ deathAlerts }) => {
|
||
const [active, setActive] = useState<ActiveNotification[]>([]);
|
||
const lastCount = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (deathAlerts.length > lastCount.current && lastCount.current > 0) {
|
||
const newAlerts = deathAlerts.slice(lastCount.current);
|
||
for (const alert of newAlerts) {
|
||
const key = ++deathKey;
|
||
setActive(prev => [...prev, { key, alert, exiting: false }]);
|
||
// Sound
|
||
try {
|
||
const ctx = new AudioContext();
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.connect(gain); gain.connect(ctx.destination);
|
||
osc.frequency.value = 440; osc.type = 'sawtooth'; gain.gain.value = 0.2;
|
||
osc.start();
|
||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
|
||
osc.stop(ctx.currentTime + 0.8);
|
||
} catch {}
|
||
// Auto-dismiss after 8s
|
||
setTimeout(() => {
|
||
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
|
||
setTimeout(() => setActive(prev => prev.filter(n => n.key !== key)), 500);
|
||
}, 8000);
|
||
}
|
||
}
|
||
lastCount.current = deathAlerts.length;
|
||
}, [deathAlerts.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
if (active.length === 0) return null;
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', top: 70, left: '50%', transform: 'translateX(-50%)', zIndex: 99999, display: 'flex', flexDirection: 'column', gap: 6, pointerEvents: 'none' }}>
|
||
{active.map(n => (
|
||
<div key={n.key} style={{
|
||
background: 'linear-gradient(135deg, #2a0a0a, #1a0000)',
|
||
border: '2px solid #cc4444',
|
||
borderRadius: 8, padding: '12px 24px', textAlign: 'center',
|
||
boxShadow: '0 0 30px rgba(204, 68, 68, 0.3)',
|
||
animation: n.exiting ? 'ml-notif-out 0.5s ease-in forwards' : 'ml-notif-in 0.5s ease-out',
|
||
}}>
|
||
<div style={{ fontSize: '1.2rem', fontWeight: 800, color: '#ff4444' }}>☠️ CHARACTER DIED ☠️</div>
|
||
<div style={{ fontSize: '1rem', fontWeight: 600, color: '#fff', marginTop: 2 }}>{n.alert.character_name}</div>
|
||
<div style={{ fontSize: '0.8rem', color: '#c88', marginTop: 2 }}>Vitae: {n.alert.vitae}%</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|