- README: Go-backend architecture, build/run via the compose override stack, WS/payload/auth/DB contracts, the branch layout (master = Go, python-legacy). - CLAUDE.md: Project Overview + Components reflect the Go services; a "Go services — build, deploy, gotchas" section (string coercion, typeless telemetry, the trinket dedup, rollback); Deploying + Suitbuilder point at the Go paths. The behavioral contracts (WS/auth/DB/routes) are kept — Go honors them; file refs to main.py/inventory-service mark the legacy source. - .gitignore: ignore .env / .env.bak-* (public repo; .env.example stays tracked). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
17 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. The production backend is Go (go-services/): a tracker service (tracker-go/) ingests player data from the MosswartMassacre DECAL plugin over /ws/position, serves the React dashboard + login/admin + the read API, and writes TimescaleDB; an inventory service (inventory-go/) handles item search, the suitbuilder solver, and inventory ingestion. Plus Grafana, a (Python) Discord rare bot, and a host-side Claude-powered assistant.
The original Python/FastAPI implementation (main.py ~4200 lines, inventory-service/) is preserved on the python-legacy branch; the Go services were validated byte-identical against it in a parallel "strangler-fig" run, then production was cut over. ⚠ The behavioral contracts below (WS, auth, DB, routes, suitbuilder) describe what Go honors. Where they cite main.py / inventory-service/, that's the legacy source that defined the contract — the live implementation is the corresponding Go handler.
Components
| Component | Where | Runs as |
|---|---|---|
| Tracker (ingest + website + read API + WS) | go-services/tracker-go/ |
Docker dereth-tracker-go, 127.0.0.1:8770 |
| Inventory (search + suitbuilder + ingestion) | go-services/inventory-go/ |
Docker inventory-go, 127.0.0.1:8772 |
| Telemetry DB (TimescaleDB) | schema in tracker-go/schema.go (replica of legacy db_async.py) |
Docker dereth-db, port 5432 |
| Inventory DB | schema in inventory-go/schema.go |
Docker inventory-db, 5433 |
| React frontend | frontend/ → built into static/ |
served by tracker-go (static file server, SPA fallback) |
| Classic v1 / legacy pages | static/classic/, static/*.html |
served by tracker-go |
| Grafana | compose service dereth-grafana |
127.0.0.1:3000, anonymous Viewer auth, proxied at /grafana/ |
| Discord rare bot | discord-rare-monitor/ (Python) |
Docker, reads the Go /ws/live |
| Overlord Agent (assistant) | agent/ |
host-side systemd service overlord-agent, 127.0.0.1:8767 |
Go services — build, deploy, gotchas
- Build on the server, no host Go needed (multi-stage distroless images). Go 1.25,
pgx/v5,coder/websocket,bwmarrin/discordgo,x/crypto/bcrypt. Sync + build + recreate:tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/" ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \ export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \ docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build dereth-tracker-go inventory-go && \ docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml \ up -d --no-deps dereth-tracker-go inventory-go' docker-compose.cutover.ymlis what makes the Go services production:READ_ONLY=false(write the prod DBs),SKIP_SCHEMA_INIT=true(trust the existing schema, run NO DDL),SHARED_SECRET/DISCORD_ACLOG_WEBHOOKfor the tracker, and the Discord bot repointed atws://dereth-tracker-go:8770/ws/live. Drop it to revert to read-only parallel mode.- Rollback =
docker compose ... up -dWITHOUT the cutover override (Go → read-only) + start the Pythondereth-tracker/inventory-service+ revert the nginxhttp://tracker_go/lines tohttp://tracker/. - ⚠ Plugin sends some numeric fields as STRINGS (
kills_per_hour,deaths,total_deaths,prismatic_taper_count). Go coerces viacoerceNum(tracker-go/reads.go) — pydantic did this implicitly; a plain number cast would write null/0. - ⚠ Telemetry must be broadcast TYPELESS to
/ws/live(stripTypeintracker-go/ingest.go). The browser ignores typeless messages and uses the 5 s/livepoll for player data; broadcasting telemetry WITH a type makes the UI overwrite the /live-derived counters and flap them 0↔value. - ⚠
inventory-goslot_names=Trinketmust exclude%bracelet%or bracelets duplicate the Wrist buckets in the suitbuilder.
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, ported to Go in inventory-go/suit_*.go (constraint-satisfaction DFS: multi-character search, armor set constraints, cantrip overlap, SSE streaming) — validated byte-identical against the legacy inventory-service/suitbuilder.py. Live endpoint: POST /suitbuilder/search (the tracker proxies /inv/suitbuilder/search); the /optimize/* solver in the legacy inventory-service/main.py was a near-duplicate and is NOT the live path. UI at /suitbuilder.html. Known limitations: no slot-aware spell filtering, equal spell weighting.
Deploying
- Go backend changes → see "Go services — build, deploy, gotchas" above (sync
go-services/, build, recreate with the cutover override).BUILD_VERSION(CalVerYYYY.M.D.HHMM-gitshorthash) shows in the frontend sidebar. - Frontend →
bash deploy-frontend.sh(complete build+copy intostatic/); the tracker servesstatic/from a bind mount, no restart needed. - Overlord Agent → unchanged (host-side Python systemd):
git pull && sudo systemctl restart overlord-agent. README.mdhas the full build/run reference. The legacy Python deploy lives on thepython-legacybranch.
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.