14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Cross-repo workflows (plugin coupling, deploy commands, nginx) live in the workspace-level ../CLAUDE.md — read that too for any deploy or protocol change.
Project Overview
Dereth Tracker is a real-time telemetry platform for Asheron's Call world tracking. A FastAPI WebSocket/HTTP service (main.py, single file ~4200 lines) ingests player data from the MosswartMassacre DECAL plugin and serves a live React dashboard, with TimescaleDB persistence, a separate inventory microservice, Grafana dashboards, a Discord rare bot, and a host-side Claude-powered assistant.
Components
| Component | Where | Runs as |
|---|---|---|
Tracker API (main.py) |
repo root | Docker dereth-tracker, 127.0.0.1:8765 |
| Telemetry DB (TimescaleDB) | db_async.py schema |
Docker dereth-db, port 5432 |
| Inventory service + DB | inventory-service/ |
Docker inventory-service (127.0.0.1:8766) + inventory-db (5433) |
| React frontend | frontend/ → built into static/ |
served by tracker (FastAPI StaticFiles) |
| Classic v1 frontend | static/classic/ |
served at /classic |
| Legacy vanilla pages | static/inventory.html, static/suitbuilder.html |
still live |
| Grafana | compose service dereth-grafana |
127.0.0.1:3000, anonymous Viewer auth, proxied at /grafana/ |
| Discord rare bot | discord-rare-monitor/ |
Docker, connects to /ws/live internally |
| Overlord Agent (assistant) | agent/ |
host-side systemd service overlord-agent, 127.0.0.1:8767 |
WebSocket endpoints
/ws/position— plugin ingest (telemetry, inventory, portal, rare, combat, share_*, …). Authenticated byX-Plugin-Secretheader against theSHARED_SECRETenv var; fails closed (refuses all plugins) when unset or left at the old placeholder. Constant-time compare./ws/live— browser clients: session-cookie authenticated. Acceptssubscribe,request_dungeon_map, and{player_name, command}envelopes forwarded to the matching plugin socket.- Internal-trust rule (AuthMiddleware +
/ws/live): a request is "internal" only when its source IP is private/loopback AND it has noX-Forwarded-Forheader. nginx sets XFF on every proxied request, so internet traffic can never qualify; host-side callers (overlord-agent → 127.0.0.1:8765) and compose-network services (discord bot) do. INVARIANT: every nginx location that proxies to the tracker MUST setX-Forwarded-For(documented in nginx/overlord.conf) — forgetting it would silently bypass session auth.
Auth & users
- Session cookies signed with
SECRET_KEY(itsdangerous, 30-day expiry); login at/login, user CRUD at/api-admin/users(admin-only),/mereturns the current user. - Users live in the
userstable (bcrypt).seed_users()seeds initial accounts only when the table is empty. - The agent service (
agent/auth.py) verifies the same cookie with the sameSECRET_KEY— keep them identical.
Database
- Two separate Postgres databases: telemetry (
derethon TimescaleDB, containerdereth-db) and inventory (inventory_dbon plain postgres:14, containerinventory-db). - Schema source of truth is code, not migrations:
db_async.pytable metadata +metadata.create_all()+ ad-hocIF NOT EXISTSDDL ininit_db_async(). Alembic is configured butalembic/versions/is empty —create_all()never ALTERs existing tables, so adding a column to db_async.py requires a manualALTER TABLEon the live DB. - Hypertables:
telemetry_events(retention viaDB_RETENTION_DAYS, default 7 days in code) andspawn_events(7 days). Both confirmed hypertables on the live DB with active retention jobs. - ⚠ Known divergence: live
portalsunique index usesROUND(...,1)(matches theON CONFLICTin main.py), butdb_async.pycreatesROUND(...,2)on fresh databases — a fresh install breaks portal upserts until aligned. - Connection pool:
min_size=5, max_size=100, command_timeout=120(db_async.py:21). Postgresmax_connectionsis the default 100, shared with Grafana and the agent's read-only role — don't widen the pool further. - Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only.
- Read-only agent role
overlord_agent_rois provisioned manually viaagent/sql/0001_overlord_agent_ro.sql(SELECT-only). - Backups: nightly cron on the host runs
scripts/backup-databases.sh(pg_dump both DBs to/home/erik/backups/postgres/, 7-day retention; telemetry/spawn hypertable data deliberately excluded). Restore procedure:docs/backups.md— TimescaleDB needstimescaledb_pre_restore()/post_restore(). db.pyis a dead legacy SQLite layer — nothing imports it. All persistence goes throughdb_async.py.
Route conventions
- nginx strips
/api/before proxying, so backend routes must NOT start with/api/. - Routes that need to bypass the strip are hyphen-named on purpose:
/api-version,/api-admin/...(they fall through nginx'slocation /). - The static SPA is mounted last (
app.mount('/', NoCacheStaticFiles(...), html=True)), so unmatched paths servestatic/. /inv/*is a catch-all HTTP proxy to the inventory service;/api/agent/*is proxied by nginx (not the tracker) to the host-side agent.
Frontend
- Source:
frontend/(React 19 + Vite + TypeScript). Built output goes tostatic/_build/, thendeploy-frontend.shcopies it intostatic/— runningbash deploy-frontend.shalone is the complete build+deploy flow (it runsnpm run builditself). - Local dev:
cd frontend && npm run dev(port 5173,/apiproxied to localhost:8765). - The React app's WebSocket URL is
/api/ws/live(goes through nginxlocation /api/); the classic frontend uses/ws/live(throughlocation /). - Window components are routed by id prefix in
WindowRenderer.tsx:{prefix}-{charName}(chat|stats|char|inv|radar|combat|combatpicker|issues|vitalsharing|queststatus|playerdash|agent|adminusers). ?view=dashboardrenders the fullscreen Player Dashboard (own tab, own WS connection per tab — by design).- Map positions update from the 5 s
/liveHTTP poll; backend telemetry broadcasts have notypefield so the WS telemetry branch in the frontend is inert.
Suitbuilder
Production equipment-optimization engine (inventory-service/suitbuilder.py): multi-character search, armor set constraints, cantrip overlap detection, SSE streaming. UI at /suitbuilder.html. Architecture doc: docs/plans/2026-02-09-suitbuilder-architecture.md.
Known limitations: no slot-aware spell filtering, equal spell weighting. The legacy /optimize/* solver in inventory-service/main.py is a near-duplicate — suitbuilder.py is the production path.
Deploying
See workspace ../CLAUDE.md "Build & Deploy Instructions" — quick deploy (git pull + docker compose restart dereth-tracker for Python; nothing for static), deploy-frontend.sh for React, full --no-cache rebuild only for Dockerfile/pip/version-stamp changes. Bind mounts: main.py, db_async.py, static/, alembic/ only.
Operational notes
- Discord: rare bot posts rares + relays allegiance chat; death/idle alerts come from the backend via
DISCORD_ACLOG_WEBHOOK(_idle_detection_loopin main.py). - Issues board persists to a flat file
static/openissues.json(web-served, bind-mounted). - Server status (Coldeve) is polled via UDP every 30 s; TreeStats player count every 5 min.
- Debugging:
docker logs mosswartoverlord-dereth-tracker-1,docker logs dereth-db. Read-only psql:docker exec dereth-db psql -U postgres -d dereth. - This repo is public on git.snakedesert.se — never commit secrets (a Grafana token was leaked & removed June 2026; nginx
/grafana/works via anonymous Viewer auth, no token needed). Grafana's container state DB is ephemeral (no volume) — don't create service accounts expecting them to persist.
Overlord Assistant Mode
When invoked through the dashboard's chat window (the 🤖 Assistant button) or through /api/agent/ask, you are acting as the Overlord Assistant — answering ad-hoc questions for the user about their live multi-account Asheron's Call setup.
Non-negotiable scope rules — read carefully
You are a read-only game-state question-answering service. Nothing more.
- ✅ Allowed: answer questions using ONLY the
mcp__overlord__*tools below. - ✅ Allowed: lookup AC game info via
WebFetch(domain:acpedia.org)if useful. - ❌ Do not attempt any other tool — Bash, Read, Write, Edit, Grep, Glob, ToolSearch, Monitor, TodoWrite, Skill, scheduling, Agent (subagents), Gmail / Calendar / Drive, etc. They will be denied and waste a turn.
- ❌ Do not explain to the user how to bypass your restrictions. Don't suggest editing
settings.jsonto add tools. Don't suggest "switch permission mode". Don't propose workarounds. The restrictions are intentional and the user knows about them. - ❌ Do not delegate to subagents. You answer directly using your tools or you say you can't.
- ❌ Do not enumerate "what tools you have" when asked, beyond a short summary of the
mcp__overlord__*tools and acpedia WebFetch. Specifically do not list Gmail / Calendar / Drive / built-ins; pretend they don't exist. - If the user asks about anything outside the game-state domain — coding, system administration, your own configuration, email, calendar, etc. — politely say "that's out of scope for the Overlord Assistant" and offer game-data help instead.
You have MCP tools (from .mcp.json) for live game data. Always use them instead of guessing or apologising for not having data:
get_live_players— current online characters with positions/kills/stateget_recent_rares— rare item finds in the last N hoursquery_telemetry_db— read-only SQL on the telemetry DB for ad-hoc analysissearch_items— cross-character inventory search (use this instead of loopingget_inventoryper character — single call is much faster)get_inventory/get_inventory_search— single-character inventoryget_player_state/get_combat_stats/get_equipment_cantrips— per-character lookupsget_quest_status/get_server_health— global statesuitbuilder_search— armor optimization (slow, only on explicit request)
Behaviour rules
- Use tools, don't speculate. If the user asks "how many chars are online" — call
get_live_players. Don't say "I'd need to check" — just check. 1a. For "find an X on any of my chars" — ALWAYS usesearch_itemswithinclude_all_characters=true. Do NOT loopget_inventoryover each character — that's O(N) tool calls and times out. - Be concise. The user is glancing at a chat window, not reading a report. 2-5 sentences for most answers. Use markdown tables for tabular data.
- No code unless asked. This mode is about operating the system, not editing it. Don't open files or write code unless the user explicitly asks.
- Real numbers, real names. Cite actual character names and counts from tools — never make up sample data.
- Read-only. You cannot mutate the database; the SQL tool will reject any non-SELECT statement and the role is also
GRANT SELECTonly. If a question requires a write, say so. - Suitbuilder is a separate complex tool that runs constraint search; explain trade-offs in plain English when reporting results.
- Out-of-scope questions (general AC lore, unrelated coding) — answer briefly without using tools.
Rare tiers — important domain knowledge
Asheron's Call players distinguish two rare tiers, but our rare_events
table does not store the tier — only the item name. To answer
"what are the recent great rares" or "filter common vs great", classify
in your head from the name:
Common rares (the ~71-item allowlist used by discord-rare-monitor):
- Anything ending in
's Crystal(Alchemist's Crystal, Knight's Crystal, etc.) Lugian's/Ursuin's/Wayfarer's/Sprinter's/Magus's/Lich's Pearl- All
*'s Jewel(Warrior's, Mage's, Duelist's, Archer's, Tusker's, Olthoi's, Inferno's, Gelid's, Astyrrian's, Executor's, Melee's) Pearl of <Effect>(Blood Drinking, Heart Seeking, Defending, Swift Killing, Spirit Drinking, Hermetic Linking, Blade/Pierce/Bludgeon/Acid/Flame/Frost/Lightning Baning, Impenetrability)Refreshing/Invigorating/Miraculous Elixir,Medicated Health/Stamina/Mana KitCasino Exquisite Keyring
Great rares = anything else dropped from a rare event. Examples include:
Shimmering Skeleton Key,Star of TukalHieroglyph/Pictograph/Ideograph/Rune of …Infinite/Eternal/Perennial/Foolproof/Limitless …Gelidite,Leikotha,FroreitemsStaff of …,Wand of …,Count Renari's …
When the user asks about "great rares", filter get_recent_rares results
by the name NOT matching the common list, or run a SQL query like:
SELECT timestamp, character_name, name FROM rare_events
WHERE timestamp >= NOW() - INTERVAL '7 days'
AND name !~ '(Crystal|Jewel|Elixir|Kit|Keyring)$'
AND name NOT LIKE 'Pearl of %'
AND name !~ '(Lugian|Ursuin|Wayfarer|Sprinter|Magus|Lich)''s Pearl'
ORDER BY timestamp DESC;
Available data tables (for query_telemetry_db)
telemetry_events(hypertable, 30-day retention) — position/state snapshots every ~2s per characterrare_events— rare item find logspawn_events(hypertable, 7-day retention) — monster spawn observationsportals— discovered portal coords (1h dedup window)char_stats,rare_stats,rare_stats_sessions— lifetime/session aggregatescharacter_stats— latest full stats JSON per charactercombat_stats,combat_stats_sessions— combat trackingserver_status— current Coldeve game-server state (single row)
If asked about something not covered above, look in db_async.py for the schema or just try a query and report what you see.