Adds an in-dashboard AI assistant that answers questions about live game
state. Designed reactively (no background loops) — every user message in
the chat window or via /api/agent/ask runs one `claude -p` invocation.
Architecture:
- New host-side FastAPI service (agent/) on 127.0.0.1:8767, OUTSIDE the
dereth-tracker Docker container because `claude` and ~/.claude
credentials live on the host.
- nginx routes /api/agent/* to the host service.
- The same browser session cookie the tracker issues authenticates
agent requests (shared SECRET_KEY).
- The agent shells out to `claude -p --session-id <uuid>` with
cwd=/home/erik/MosswartOverlord. Sessions persist as JSONL on disk
via Claude Code's built-in machinery.
- An MCP stdio server (agent/mcp_overlord.py) exposes tools to Claude:
get_live_players, get_recent_rares, query_telemetry_db (read-only,
parsed by sqlglot to reject DML/DDL), get_player_state, get_inventory,
get_inventory_search, get_combat_stats, get_equipment_cantrips,
get_quest_status, get_server_health, suitbuilder_search.
- Read-only PG role (overlord_agent_ro) is the second line of defense
on the SQL tool — even a parser bypass can't mutate.
Frontend:
- AgentWindow.tsx — draggable chat window with localStorage-pinned
session UUID, "New Chat" button, on-mount rehydration from
/agent/sessions/{id}/history (parses Claude Code's JSONL).
- Wired into WindowRenderer + Sidebar (🤖 Assistant button).
Operational:
- systemd unit (overlord-agent.service) + install.sh.
- agent/README.md documents env vars, deploy flow, smoke tests.
- nginx/overlord.conf gets a new /api/agent/ location with 180s timeout.
- CLAUDE.md gets an "Overlord Assistant Mode" section briefing the
agent on which tools to use and how to behave.
NOT YET DEPLOYED — server still needs:
1. Apply agent/sql/0001_overlord_agent_ro.sql + ALTER ROLE password
2. Add AGENT_DB_DSN to /home/erik/MosswartOverlord/.env
3. bash agent/install.sh (creates venv, installs unit, starts service)
4. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf to nginx + reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commit moved inventory_delta handling to fire-and-forget
asyncio tasks. That removed the WS-loop blockage but introduced a race:
when the same character generated multiple deltas in quick succession
(mana burn, ID refresh, loot bursts), the tasks ran concurrently and
inventory-service's DELETE-then-INSERT path raced on the items table:
asyncpg.exceptions.ForeignKeyViolationError:
update or delete on table 'items' violates foreign key constraint
'item_combat_stats_item_id_fkey'
The 500 errors caused inventory_delta updates to be dropped silently
(likely the source of the 'items in wrong container' bug the user
reported earlier — every delta returning 500 means the DB never updates).
Fix: per-character asyncio.Lock — deltas for the same character serialize,
deltas for different characters still run in parallel. Restores correctness
without losing the non-blocking-WS-loop benefit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues causing plugin WS disconnects on heavy-loot characters:
1. inventory_delta processing was awaiting an httpx POST to inventory-
service inline within the WS receive loop. Each delta also created a
fresh httpx.AsyncClient (no connection pool reuse). When inventory-
service was slow under load, the receive loop blocked, keepalives
stopped flowing, and the connection eventually dropped (especially
for characters spamming deltas: Elliot was reconnecting ~every 4 min).
Fix: process each delta as an asyncio.create_task() — the WS receive
loop returns immediately to read the next message. Use a shared
httpx.AsyncClient with connection pooling.
2. websocket.receive_text() raises RuntimeError ("Need to call accept
first") instead of WebSocketDisconnect in some race conditions when
the connection closes mid-await. The receive loop only caught
WebSocketDisconnect, so RuntimeError propagated up as an exception
traceback in logs.
Fix: catch RuntimeError and log as a clean disconnect.
Also: log close code/reason on WebSocketDisconnect so we can tell apart
clean closes (1000/1001) from network drops (1006) etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The live host nginx config (/etc/nginx/sites-enabled/overlord) was not
tracked in git, leading to drift. This commit checks in a source-of-truth
copy under nginx/overlord.conf with a deploy procedure documented at the
top of the file.
Includes the proxy_read_timeout/proxy_send_timeout 1d settings for both
WebSocket location blocks (/websocket/ and /). Without these, nginx's
default 60s timeout drops idle plugin connections in a reconnect loop —
the symptom users saw was "WebSocket error … State: Aborted" every
~60s on idle characters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The floating version badge scrolled awkwardly and wasn't necessary
now that the bind-mount/deploy issue is fixed. The existing ml-version
inside the Sidebar is sufficient.
Also removed the temporary [INV_DEBUG] console logs from useLiveData
and InventoryWindow — the inventory live-update bug is confirmed fixed.
Kept the per-character inventoryVersions fix and the cache-buster on
the refetch URL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commits built into static/_build/ but forgot to run the
deploy script that copies the output to static/index.html and
static/assets/. The web server serves from static/, so none of the
previous frontend changes (per-character inventory version, debug logs,
version badge) were actually reaching the browser.
This commit runs deploy-frontend.sh which copies _build/ → static/,
replacing the stale index-BHGeM5hq bundle with the current one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Small yellow badge fixed at position (4, 4) showing the running build
version. Helps visually confirm which bundle a browser is loading when
diagnosing cache issues.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Temporary instrumentation to diagnose why InventoryWindow doesn't refresh
on inventory_delta. Three log points:
- useLiveData: logs when inventory_delta arrives and version bump
- InventoryWindow effect: logs every run with state
- InventoryWindow fetch: logs when debounce fires and result count
Also added cache-buster (_t=timestamp) to the refetch URL in case HTTP
caching is masking fresh data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The inventoryVersion counter in useLiveData was a single global value that
bumped on every inventory_delta for any character. With 60+ active chars
all generating deltas, the global counter advanced multiple times per
second.
InventoryWindow's debounce effect watched this global counter, so every
bump reset its 2-second fetch timer. Since bumps arrived faster than 2s,
the fetch never fired — the window appeared frozen until the user closed
and reopened it (which triggered the initial-fetch effect).
Fix: make inventoryVersions a Map<string, number> keyed by character name.
Each inventory_delta now only bumps its own character's counter, so an
open window's debounce correctly fires 2s after its character's last
delta, ignoring unrelated traffic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
timescaledb-tune had configured shared_buffers=96396MB — three times the
physical RAM of the host. The kernel was giving PG everything it could
(~30GB of shared memory), leaving <100MB free for everything else.
This caused the OS page cache to be constantly evicted, every query to
hit disk, and telemetry writes to balloon to 20+ seconds.
New settings (standard 25/50 rule for 32GB):
- shared_buffers: 96GB → 8GB
- effective_cache_size: 16GB (query planner hint)
- work_mem: 16MB per operation
- maintenance_work_mem: 1GB (for vacuum/index)
- max_wal_size: 4GB
Requires a db container restart to take effect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Database cleanup:
- Converted spawn_events to a TimescaleDB hypertable with 7-day retention.
Previously a regular table growing unbounded — had reached 482M rows/66GB
from June 2025. Manual migration copied last 7 days (12M rows) to a new
hypertable, swapped names, and dropped the old table.
Result: DB shrunk from 77GB → 12GB.
- Dropped server_health_checks table entirely. It was write-only (850K rows,
134MB) — only current state in server_status is actually read. Eliminated
the insert from monitor_server_health().
Telemetry handler cleanup:
- Removed 4 per-message INFO log lines (TELEMETRY_RECEIVED, DB_WRITE_ATTEMPT,
DB_WRITE_SUCCESS, PROCESSING_COMPLETE). At 60+ chars × every 2s = hundreds
of log lines/sec. Replaced with single SLOW_* warnings above 500ms/1000ms
thresholds.
- Removed redundant pool-size introspection (try/except + hasattr) on every
telemetry message — useless noise in the hot path.
- Removed debug cache-miss and kill-delta logs.
Log level:
- docker-compose.yml: dereth-tracker LOG_LEVEL DEBUG → INFO (was dumping
entire inventory_delta JSON payloads for every item update).
- inventory-service LOG_LEVEL DEBUG → INFO.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Browser WS clients can now send {"type": "subscribe", "message_types": [...]}
to only receive specific message types. Default is all (no change for browsers).
- Discord bot subscribes to only "rare" and "chat" — eliminates 82GB+ of
unnecessary telemetry/vitals/inventory traffic.
- Idle detection now has a 5-minute grace period before firing Discord alerts,
preventing false positives on brief idle states.
- Added DISCORD_ACLOG_WEBHOOK env var to docker-compose.yml for death/idle alerts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The --reload flag without watchfiles installed causes uvicorn to
fall back to a polling-based file watcher that busy-loops at 100% CPU.
This was burning an entire core 24/7 doing nothing useful in production.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Discord bot connects to /ws/live from the Docker internal network
(172.x.x.x) but has no session cookie, causing 4401 auth failures.
Now: connections from Docker internal network (172.x.x.x), localhost,
or ::1 skip the session cookie check. External connections (through
Nginx) still require authentication.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Quest Status window (📜 Quests in sidebar):
- Fetches GET /quest-status API (polls every 30s)
- Grid: characters as rows × all unique quests as columns
- "READY" shown in green, countdowns in yellow, missing as dash
- Quest names shortened (removes "Timer", "Pickup" suffixes)
- Sticky header row, scrollable body
- Replaces broken quest-status.html link
Player Dashboard window (👥 Dashboard in sidebar):
- Sortable table of all online characters
- Columns: Character, State, KPH, Session kills, Total kills,
Rares (total + session), Deaths, Uptime, HP%, Tapers
- Click column headers to sort (ascending/descending toggle)
- State badges: green=combat/hunt, red=other, gray=idle
- KPH in green, rares in gold, deaths in red (if > 0)
- HP% color-coded: green >80%, yellow >40%, red below
Sidebar changes:
- Removed broken /quest-status.html external link
- Added 👥 Dashboard + 📜 Quests as window opener buttons
- Both lazy-loaded (only fetched when first opened)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Command history:
- Up/Down arrow keys browse sent command history (like bash/console)
- 50 commands stored per character in localStorage
- Persists across page reloads and browser sessions
- Current input preserved when browsing (restored on Down past end)
- Duplicates kept (matches user preference)
Smart auto-scroll:
- New messages only auto-scroll if user is already at the bottom
- If user has scrolled up to read history, it stays put
- Sending a message snaps back to bottom
- 30px threshold for "at bottom" detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Non-active, non-idle VTank states (nav, turn_in_quests, etc.) now
display in red instead of amber/yellow in both:
- Map sidebar: .ml-meta-pill.other (red background + text)
- Dashboard cards: .badge-other (red background + text)
Green = combat/hunt, Red = nav/other states, Gray = idle/default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- v1 vanilla JS frontend moved to /classic (static/classic/)
- v2 React app now serves at / (root)
- Vite base changed from /v2/ to /
- Assets at /assets/, service worker at /sw.js
- /classic still works — all v1 files preserved with relative paths
- /v2 still works as before (build output unchanged)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. React.memo on WindowRenderer — prevents re-renders when parent
state changes but no windows are affected
2. Coordinate display via direct DOM ref — no React state updates
on mouse move (was triggering re-renders on every pixel)
3. useDeferredValue for sidebar vitals + player list — React
prioritizes map interactions over stat text updates
4. Chat messages in ref — stores in useRef instead of useState,
only bumps a version counter for re-render. Eliminates a
new Map() allocation on every chat message.
5. Lazy-load 8 window components — InventoryWindow, CharacterWindow,
RadarWindow, CombatStatsWindow, IssuesWindow, VitalSharingWindow,
StatsWindow, CombatPickerWindow all loaded on first open.
Main bundle dropped from 278KB to 211KB (24% reduction).
6. Preload critical assets — dereth.png, backpack icon, dungeon_tiles.json
via <link rel="preload"> in index.html for instant map render.
7. Bundle splitting — React runtime extracted to separate 12KB chunk
(cached independently). Window components split into 8 chunks.
Total: 13 chunks vs previous 2.
8. Service worker — caches map images, icon sprites, and dungeon tiles.
Icon images cached on first fetch. Repeat page loads serve from
cache instantly. Auto-cleans old cache versions.
Net result:
- Initial load: 211KB main + 17KB CSS (was 278KB + 17KB)
- React cached separately: 12KB
- Windows load on demand: 1-15KB each
- Dashboard with Recharts: 425KB (unchanged, still lazy)
- Map images/icons: cached by service worker after first load
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All features functional: map view, sidebar, player dots/trails/heatmap/portals,
draggable windows (chat/stats/inventory/character/radar/combat/issues/vitals),
session+lifetime combat stats, 60-color palette, rare notifications, dungeon
radar, version display. Performance: code-split Recharts, direct DOM pan/zoom,
deferred player list, memoized derived data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The combat_stats DB column stores stats_data as JSON, but SQLAlchemy
returns it as a string (not a parsed dict). This caused:
- _combat_lifetime_cache loaded a string, merge failed silently
- API endpoints returned string instead of object for lifetime
- Frontend saw lifetime as a string, couldn't read .monsters
Fix: parse JSON string with json.loads() wherever stats_data is read
from DB — in the lifetime cache loader, single-character endpoint,
and all-characters endpoint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin now sends only session data (lifetime=null). Backend computes
the delta between consecutive session snapshots (new - previous) and
merges it into a persisted lifetime record in the combat_stats table.
- _combat_last_session: tracks last-seen session per char:session_id
- _combat_lifetime_cache: in-memory lifetime per character
- _combat_session_delta(): computes diff between two cumulative snapshots
- _combat_merge_into_lifetime(): adds delta totals into lifetime record
- First snapshot for a new session = entire session is the delta
- Lifetime loaded from DB on first message, cached in memory after
- Broadcast enriched with computed lifetime so frontend gets both
This means session resets on login but lifetime persists in DB across
all sessions. The two will now show different data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dashboard toggle moved from sidebar header to tool links area
alongside Suitbuilder, Inv Search, Debug, Quests
- Combat sidebar button now opens a character picker window
(combatpicker prefix) that lists all online characters — click
one to open their full combat stats window
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The zoom-to-player effect was re-triggering on every telemetry
update (every 2s) because selectedPlayer stayed set and players
array kept changing. Now tracks lastZoomedRef — zoom only fires
once per selection. Map is immediately free to pan/zoom after.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
apiFetch adds /api prefix, so /api-version became /api/api-version
which was wrong. Use raw fetch with correct path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Player click → zoom: clicking a player in sidebar or on map dot
zooms to their position at 3x zoom, centered on screen. Click
again to deselect. Uses direct DOM transform (no React state).
2. Selected dot blink: selected player dot gets 10px size + blink
animation (0.6s step-end infinite) matching v1's .dot.highlight.
3. Version display: fetches /api-version on mount, shows "vX.Y.Z"
in small text positioned just right of sidebar (fixed, top: 6px).
4. Missing sidebar buttons: added Combat Stats (⚔️) alongside
existing Issues (📋) and Vital Sharing (🤝) in SidebarWindowButtons.
5. Rare notification: added 🎆 emojis to "LEGENDARY RARE!" title
matching v1's notification text.
6. Dungeon map in radar — verbatim port from v1 lines 3596-3930:
- loadDungeonTiles(): fetches dungeon_tiles.json, processes each
tile image (color remap: UB source colors → display colors,
white → transparent, black → semi-transparent)
- cellRotation(): maps rotation values to radians (v1's exact logic)
- Dungeon rendering: sorts z_levels (current floor on top at 85%
opacity, others at 12%), draws each cell with per-cell rotation,
uses processed tile canvases or colored rectangle fallback
- Requests dungeon map via WebSocket when radar detects dungeon
- Caches dungeon maps on window.__dungeonMapCache
- Overworld map: fixed srcSize calculation to use range * pixPerCoord
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Map pan/zoom via direct DOM mutation (bypass React state)
- txRef stores {scale, offX, offY}, applyTransform() writes
directly to groupRef.style.transform
- Zero React re-renders during pan/zoom — smooth 60fps
- Removed MapTransformContext dependency (dead code now)
2. Code-split Recharts via React.lazy()
- DashboardView (with all Recharts components) is a separate chunk
- Main bundle: 274KB (was 694KB — 60% reduction)
- Dashboard chunk: 425KB (loaded only on demand)
- Map view loads instantly without Recharts overhead
3. useDeferredValue for player list
- Kill counters, KPH, rares in sidebar use deferred rendering
- React prioritizes map interactions over stat text updates
- Reduces unnecessary re-renders during WS message bursts
4. useMemo for derived data in MapLayout
- players array and vitalsMap memoized on characters ref
- Prevents child component re-renders when Map identity changes
but content is the same
5. Removed MapTransformProvider wrapper (no longer needed)
Total impact: ~60% smaller initial load, ~10x fewer re-renders
during active WebSocket streaming, zero-latency pan/zoom.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Property ID maps were wrong (made-up IDs 360-390). Now uses the
exact same IDs as v1 script.js lines 1843-1876:
- TS_AUGMENTATIONS: IDs 218-328 (30 augmentations)
- TS_AURAS: IDs 333-365 (11 luminance auras)
- TS_RATINGS: IDs 370-379 (8 ratings)
- TS_SOCIETY: IDs 287-289 (3 societies)
- TS_MASTERIES: IDs 354-362 with TS_MASTERY_NAMES lookup
- TS_GENERAL: IDs 181-390 (chess, fishing, total augs, aetheria, enlightenment)
- societyRank() function matching v1's _tsSocietyRank()
Other tab now shows General + Masteries + Society sections (was
only showing allegiance). Each section has its own header matching
v1's ts-section-title styling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The inventory service doesn't return items_capacity directly — it's
in enhanced_properties.ItemSlots_Decal. Updated normalizer to read
from there. Also defaults to 24 (standard AC pack size) with ||
instead of ?? to catch 0/undefined/null. Removed debug console.logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of relying on the packItems Map (which may have key matching
issues), count pack children directly by filtering the normalized
items array for items whose container_id matches the pack's item_id.
Also removed debug console.log spam from WindowRenderer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Increased bar width to 7px with #222 background and #666 border for
better contrast. Added tooltip showing "X% full" on hover. Minimum
2px fill height when non-empty so even nearly-empty packs show a sliver.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>