# 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 by `X-Plugin-Secret` header. ⚠ The secret is currently the hardcoded placeholder `"your_shared_secret"` at `main.py:994`; the `SHARED_SECRET` env var is NOT read (known issue — fix both repos together). - `/ws/live` — browser clients: session-cookie authenticated; clients from the Docker network (172.x / loopback) are trusted by IP. Accepts `subscribe`, `request_dungeon_map`, and `{player_name, command}` envelopes forwarded to the matching plugin socket. - ⚠ Because nginx → docker-proxy makes ALL external traffic appear as 172.x to the app, the IP-trust shortcut currently bypasses cookie auth for proxied browsers (see workspace security notes before relying on auth). ## Auth & users - Session cookies signed with `SECRET_KEY` (itsdangerous, 30-day expiry); login at `/login`, user CRUD at `/api-admin/users` (admin-only), `/me` returns the current user. - Users live in the `users` table (bcrypt). `seed_users()` seeds initial accounts only when the table is empty. - The agent service (`agent/auth.py`) verifies the same cookie with the same `SECRET_KEY` — keep them identical. ## Database - **Two separate Postgres databases**: telemetry (`dereth` on TimescaleDB, container `dereth-db`) and inventory (`inventory_db` on plain postgres:14, container `inventory-db`). - **Schema source of truth is code, not migrations**: `db_async.py` table metadata + `metadata.create_all()` + ad-hoc `IF NOT EXISTS` DDL in `init_db_async()`. Alembic is configured but `alembic/versions/` is empty — `create_all()` never ALTERs existing tables, so **adding a column to db_async.py requires a manual `ALTER TABLE` on the live DB**. - Hypertables: `telemetry_events` (retention via `DB_RETENTION_DAYS`, default 7 days in code) and `spawn_events` (7 days). Both confirmed hypertables on the live DB with active retention jobs. - ⚠ Known divergence: live `portals` unique index uses `ROUND(...,1)` (matches the `ON CONFLICT` in main.py), but `db_async.py` creates `ROUND(...,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`). Postgres `max_connections` is 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_ro` is provisioned manually via `agent/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 needs `timescaledb_pre_restore()/post_restore()`. - `db.py` is a dead legacy SQLite layer — nothing imports it. All persistence goes through `db_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's `location /`). - The static SPA is mounted last (`app.mount('/', NoCacheStaticFiles(...), html=True)`), so unmatched paths serve `static/`. - `/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 to `static/_build/`, then `deploy-frontend.sh` copies it into `static/` — **running `bash deploy-frontend.sh` alone is the complete build+deploy flow** (it runs `npm run build` itself). - Local dev: `cd frontend && npm run dev` (port 5173, `/api` proxied to localhost:8765). - The React app's WebSocket URL is `/api/ws/live` (goes through nginx `location /api/`); the classic frontend uses `/ws/live` (through `location /`). - 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=dashboard` renders the fullscreen Player Dashboard (own tab, own WS connection per tab — by design). - Map positions update from the 5 s `/live` HTTP poll; backend telemetry broadcasts have no `type` field 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_loop` in 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.json` to 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/state - `get_recent_rares` — rare item finds in the last N hours - `query_telemetry_db` — read-only SQL on the telemetry DB for ad-hoc analysis - `search_items` — **cross-character** inventory search (use this instead of looping `get_inventory` per character — single call is much faster) - `get_inventory` / `get_inventory_search` — single-character inventory - `get_player_state` / `get_combat_stats` / `get_equipment_cantrips` — per-character lookups - `get_quest_status` / `get_server_health` — global state - `suitbuilder_search` — armor optimization (slow, only on explicit request) ### Behaviour rules 1. **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 use `search_items`** with `include_all_characters=true`. Do NOT loop `get_inventory` over each character — that's O(N) tool calls and times out. 2. **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. 3. **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. 4. **Real numbers, real names.** Cite actual character names and counts from tools — never make up sample data. 5. **Read-only.** You cannot mutate the database; the SQL tool will reject any non-SELECT statement and the role is also `GRANT SELECT` only. If a question requires a write, say so. 6. **Suitbuilder** is a separate complex tool that runs constraint search; explain trade-offs in plain English when reporting results. 7. **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 ` (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 Kit` - `Casino Exquisite Keyring` **Great rares** = anything else dropped from a rare event. Examples include: - `Shimmering Skeleton Key`, `Star of Tukal` - `Hieroglyph/Pictograph/Ideograph/Rune of …` - `Infinite/Eternal/Perennial/Foolproof/Limitless …` - `Gelidite`, `Leikotha`, `Frore` items - `Staff 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: ```sql 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 character - `rare_events` — rare item find log - `spawn_events` (hypertable, 7-day retention) — monster spawn observations - `portals` — discovered portal coords (1h dedup window) - `char_stats`, `rare_stats`, `rare_stats_sessions` — lifetime/session aggregates - `character_stats` — latest full stats JSON per character - `combat_stats`, `combat_stats_sessions` — combat tracking - `server_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.