Compare commits

..

No commits in common. "master" and "lundberg_onlinetime_deaths" have entirely different histories.

17037 changed files with 682 additions and 153429 deletions

16
.gitignore vendored
View file

@ -1,18 +1,2 @@
.venv .venv
__pycache__ __pycache__
static/v2/
frontend/node_modules/
# Secrets — the server-side env files hold SHARED_SECRET, SECRET_KEY, DB
# passwords, and the Discord token. This repo is PUBLIC — never commit them.
# .env.example stays tracked as the template.
.env
.env.bak-*
# Claude Code config — never commit. The production agent's strict
# permissions live server-side at /var/lib/overlord-agent/.claude/
# (and via CLI flags in agent/claude_wrapper.py). The repo stays
# permission-neutral so devs can `claude` interactively here without
# inheriting production-agent restrictions.
.claude/
.superpowers/

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"overlord": {
"command": "/home/erik/MosswartOverlord/agent/.venv/bin/python",
"args": ["-m", "agent.mcp_overlord"],
"env": {
"PYTHONPATH": "/home/erik/MosswartOverlord"
}
}
}
}

154
AGENTS.md
View file

@ -1,154 +0,0 @@
# AGENTS.md
Guidance for coding agents working in `MosswartOverlord` (Dereth Tracker).
Read shared integration rules first: `../AGENTS.md`.
## Scope and priorities
- This repo is a Python/FastAPI multi-service project with Docker-first workflows.
- Primary services: `main.py` (telemetry API + WS + static frontend), `inventory-service/main.py` (inventory + suitbuilder), `discord-rare-monitor/discord_rare_monitor.py` (Discord bot).
- Favor minimal, targeted changes over broad refactors.
## Local rule sources
- Additional project guidance exists in `CLAUDE.md`; follow it when relevant.
- Cursor/Copilot rule discovery is documented centrally in `../AGENTS.md`.
## Environment and dependencies
- Python versions in Dockerfiles: 3.12 (main + bot), 3.11 (inventory-service).
- Databases: PostgreSQL/TimescaleDB for telemetry; PostgreSQL for inventory.
- Core Python deps: FastAPI, Uvicorn, SQLAlchemy, databases, asyncpg, httpx.
- Bot deps: `discord.py`, `websockets`.
## Build and run commands
## Docker (recommended)
- Start all services: `docker compose up -d`
- Rebuild app service after source changes (no cache): `docker compose build --no-cache dereth-tracker`
- Redeploy app service: `docker compose up -d dereth-tracker`
- Rebuild inventory service: `docker compose build --no-cache inventory-service`
- Rebuild Discord bot: `docker compose build --no-cache discord-rare-monitor`
- Follow logs (app): `docker logs mosswartoverlord-dereth-tracker-1`
- Follow logs (telemetry DB): `docker logs dereth-db`
## Local (without Docker)
- Main API dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8765`
- Inventory service dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8000` (from `inventory-service/`)
- Data generator: `python generate_data.py`
- Discord bot run: `python discord-rare-monitor/discord_rare_monitor.py`
## Lint/format commands
- Repo formatter target: `make reformat`
- What it does: runs `black *.py` in repo root.
- Prefer formatting changed files before finalizing edits.
- No repo-level Ruff/Flake8/isort/mypy config files were found.
## Test commands
- There is no conventional `tests/` suite configured in this repo.
- Existing executable test script: `python discord-rare-monitor/test_websocket.py`
- This script validates rare classification and WebSocket handling.
- It expects a reachable server at `ws://localhost:8765/ws/position` for connection checks.
## Single-test guidance (important)
- For the current codebase, a single targeted test means running the script above.
- Practical single-test command:
- `python discord-rare-monitor/test_websocket.py`
- The script is not pytest-based; use stdout/log output for pass/fail interpretation.
- If pytest is introduced later, preferred pattern is:
- `python -m pytest path/to/test_file.py::test_name -q`
## Service-specific quick checks
- Main health endpoint: `GET /debug`
- Live data endpoint: `GET /live`
- Trails endpoint: `GET /trails`
- Plugin WS endpoint: `/ws/position` (authenticated via X-Plugin-Secret)
- Browser WS endpoint: `/ws/live` (session-cookie authenticated; internal Docker-network clients trusted by IP)
- Inventory service endpoint family: `/search/*`, `/inventory/*`, `/suitbuilder/*`
## Repo-specific architecture notes
- Telemetry DB schema is in `db_async.py` (SQLAlchemy Core tables).
- Inventory DB schema is in `inventory-service/database.py` (SQLAlchemy ORM models).
- Static frontend is served from `static/` by FastAPI.
- Keep inventory-service enum loading paths intact (`comprehensive_enum_database_v2.json`, fallback JSON).
## Code style conventions observed
## Imports and module structure
- Use standard-library imports first, then third-party, then local imports.
- Keep import groups separated by one blank line.
- Prefer explicit imports over wildcard imports.
- In existing files, `typing` imports are common (`Dict`, `List`, `Optional`, `Any`).
- Avoid introducing circular imports; shared helpers belong in dedicated modules.
## Formatting and layout
- Follow Black-compatible formatting (88-char style assumptions are acceptable).
- Use 4 spaces, no tabs.
- Keep functions focused; extract helpers for repeated logic.
- Maintain existing docstring style (triple double quotes for module/function docs).
- Preserve readable logging statements with context-rich messages.
## Types and data models
- Add type hints for new functions and non-trivial variables.
- Use Pydantic models for request/response payload validation in FastAPI layers.
- Keep DB schema changes explicit in SQLAlchemy model/table definitions.
- Prefer precise types over `Any` when practical.
- For optional values, use `Optional[T]` or `T | None` consistently within a file.
## Naming conventions
- Functions/variables: `snake_case`.
- Classes: `PascalCase`.
- Constants/env names: `UPPER_SNAKE_CASE`.
- Endpoint handlers should be action-oriented and descriptive.
- Database table/column names should remain stable unless migration is planned.
## Error handling and resilience
- Prefer explicit `try/except` around external I/O boundaries:
- DB calls, WebSocket send/recv, HTTP calls, file I/O, JSON parsing.
- Log actionable errors with enough context to debug production issues.
- Fail gracefully for transient network/database errors (retry where already patterned).
- Do not swallow exceptions silently; at minimum log at `warning` or `error`.
- Keep user-facing APIs predictable (consistent JSON error responses).
## Logging conventions
- Use module-level logger: `logger = logging.getLogger(__name__)`.
- Respect `LOG_LEVEL` environment variable patterns already present.
- Prefer structured, concise messages; avoid noisy logs in hot loops.
- Keep emoji-heavy logging style only where already established in file context.
## Database and migrations guidance
- Be careful with uniqueness/index assumptions (especially portal coordinate rounding logic).
- Validate any schema-affecting changes against Dockerized Postgres services.
## Frontend/static guidance
- Preserve existing API base path assumptions used by frontend scripts.
- Reverse-proxy prefix behavior (`/api`) is documented in `../AGENTS.md`; keep frontend/backend paths aligned.
## Secrets and configuration
- Never hardcode secrets/tokens in commits.
- Use env vars (`SHARED_SECRET`, `POSTGRES_PASSWORD`, bot token variables).
- Keep defaults safe for local dev, not production credentials.
## Change management for agents
- Keep patches small and scoped to the requested task.
- Update docs when behavior, endpoints, or run commands change.
- If adding new tooling (pytest/ruff/mypy), include config and command docs in this file.
- For cross-repo payload changes, follow `../AGENTS.md` checklist and update both sides.

186
CLAUDE.md
View file

@ -1,186 +0,0 @@
# 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:
```bash
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.yml`** is 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_WEBHOOK` for the tracker, and the Discord bot repointed at `ws://dereth-tracker-go:8770/ws/live`. Drop it to revert to read-only parallel mode.
- **Rollback** = `docker compose ... up -d` WITHOUT the cutover override (Go → read-only) + start the Python `dereth-tracker`/`inventory-service` + revert the nginx `http://tracker_go/` lines to `http://tracker/`.
- ⚠ **Plugin sends some numeric fields as STRINGS** (`kills_per_hour`, `deaths`, `total_deaths`, `prismatic_taper_count`). Go coerces via `coerceNum` (`tracker-go/reads.go`) — pydantic did this implicitly; a plain number cast would write null/0.
- ⚠ **Telemetry must be broadcast TYPELESS** to `/ws/live` (`stripType` in `tracker-go/ingest.go`). The browser ignores typeless messages and uses the 5 s `/live` poll for player data; broadcasting telemetry WITH a type makes the UI overwrite the /live-derived counters and flap them 0↔value.
- ⚠ `inventory-go` `slot_names=Trinket` must exclude `%bracelet%` or bracelets duplicate the Wrist buckets in the suitbuilder.
## WebSocket endpoints
- `/ws/position` — plugin ingest (telemetry, inventory, portal, rare, combat, share_*, …). Authenticated by `X-Plugin-Secret` header against the `SHARED_SECRET` env var; fails closed (refuses all plugins) when unset or left at the old placeholder. Constant-time compare.
- `/ws/live` — browser clients: session-cookie authenticated. Accepts `subscribe`, `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 **no `X-Forwarded-For` header**. 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 set `X-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), `/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, 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` (CalVer `YYYY.M.D.HHMM-gitshorthash`) shows in the frontend sidebar.
- **Frontend**`bash deploy-frontend.sh` (complete build+copy into `static/`); the tracker serves `static/` from a bind mount, no restart needed.
- **Overlord Agent** → unchanged (host-side Python systemd): `git pull && sudo systemctl restart overlord-agent`.
- `README.md` has the full build/run reference. The legacy Python deploy lives on the `python-legacy` branch.
## 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 <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 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.

View file

@ -1,49 +0,0 @@
# Dockerfile for Dereth Tracker application
# Base image: lightweight Python runtime
FROM python:3.12-slim
## Set application working directory
WORKDIR /app
# Upgrade pip and install required Python packages without caching
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir \
fastapi \
uvicorn \
pydantic \
websockets \
databases[postgresql] \
sqlalchemy \
alembic \
psycopg2-binary \
httpx \
bcrypt \
itsdangerous
## Copy application source code and migration scripts into container
COPY static/ /app/static/
COPY main.py /app/main.py
COPY db.py /app/db.py
COPY db_async.py /app/db_async.py
COPY alembic.ini /app/alembic.ini
COPY alembic/ /app/alembic/
COPY Dockerfile /Dockerfile
## Expose the application port to host
EXPOSE 8765
## Build version (CalVer + git hash, set via --build-arg)
ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION
## Default environment variables for application configuration
## NOTE: no SHARED_SECRET default here on purpose — main.py fails closed
## (refuses plugin connections) unless a real value arrives via compose/.env.
ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \
DB_MAX_SIZE_MB=2048 \
DB_RETENTION_DAYS=7 \
DB_MAX_SQL_LENGTH=1000000000 \
DB_MAX_SQL_VARIABLES=32766 \
DB_WAL_AUTOCHECKPOINT_PAGES=1000
## Launch the FastAPI app using Uvicorn
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8765","--workers","1","--no-access-log","--log-level","warning"]

View file

@ -1,4 +1,2 @@
# Reformat Python code using Black formatter
.PHONY: reformat
reformat: reformat:
black *.py black *py

238
README.md
View file

@ -1,155 +1,147 @@
# Mosswart Overlord (Dereth Tracker) # Dereth Tracker
Real-time telemetry, inventory, and analytics platform for Asheron's Call — Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a SQLite database, and provides both a live map interface and an analytics dashboard.
driven by a firehose of WebSocket events from the companion
[MosswartMassacre](https://github.com/SawatoMosswartsEnjoyersClub/MosswartMassacre)
DECAL plugin running on 60+ characters.
**The production backend is written in Go** (`go-services/`). It replaced the ## Table of Contents
original Python/FastAPI implementation via a strangler-fig migration: the Go - [Overview](#overview)
services ran in parallel against live traffic until every endpoint was proven - [Features](#features)
byte-identical, then production was cut over. The Python implementation is - [Requirements](#requirements)
preserved on the `python-legacy` branch. - [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [API Reference](#api-reference)
- [Frontend](#frontend)
- [Database Schema](#database-schema)
- [Sample Payload](#sample-payload)
- [Contributing](#contributing)
--- ## Overview
## Architecture This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data.
- SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript.
- An analytics dashboard for visualizing kills and session metrics.
``` ## Features
MosswartMassacre plugin ──wss──> nginx ──> Go tracker (tracker-go) ──> dereth (TimescaleDB)
(60+ game clients) │ │
│ ├──HTTP──> Go inventory (inventory-go) ──> inventory_db
Browsers ──https──────────────────> nginx │
│ └──/ws/live──> Discord rare bot (relays rares + chat)
└──> Grafana (/grafana/) death/idle alerts → Discord webhook
```
| Component | Path | Runs as | Notes | - **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
|---|---|---|---| - **GET /live**: Fetch active players seen in the last 30 seconds.
| **Tracker** (ingest + website + read API + WS) | `go-services/tracker-go/` | Docker `dereth-tracker-go`, 127.0.0.1:8770 | serves the React frontend, login/admin, the plugin `/ws/position`, browser `/ws/live`, and the full read API; writes the `dereth` DB | - **GET /history**: Retrieve historical telemetry data with optional time filtering.
| **Inventory** (search + suitbuilder + ingestion) | `go-services/inventory-go/` | Docker `inventory-go`, 127.0.0.1:8772 | normalized item search, the suitbuilder solver (SSE), inventory ingestion; writes `inventory_db` | - **GET /debug**: Health check endpoint.
| Telemetry DB | TimescaleDB | Docker `dereth-db`, 5432 | hypertables `telemetry_events`, `spawn_events` | - **Live Map**: Interactive map interface with panning, zooming, and sorting.
| Inventory DB | postgres:14 | Docker `inventory-db`, 5433 | 7-table normalized item schema | - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
| React frontend | `frontend/``static/` | served by `tracker-go` | unchanged by the migration — same paths, same API |
| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`; inventory search is now the React page at `/?view=inventory` |
| Grafana | compose `dereth-grafana` | 127.0.0.1:3000 | anonymous Viewer auth, proxied at `/grafana/` |
| Discord rare bot | `discord-rare-monitor/` (Python) | Docker, reads Go `/ws/live` | posts rares + relays allegiance chat |
| Overlord Agent (assistant) | `agent/` | host-side systemd `overlord-agent`, 127.0.0.1:8767 | shells out to `claude -p`; outside Docker by design |
**Stack:** Go 1.25 (stdlib `net/http` with 1.22 method+path routing, `pgx/v5`, ## Requirements
`coder/websocket`, `bwmarrin/discordgo`, `golang.org/x/crypto/bcrypt`), distroless
multi-stage images. React 19 + Vite + TypeScript. PostgreSQL/TimescaleDB. nginx
reverse proxy (host-side). Unlike the old single-worker Python service, the Go
tracker uses `GOMAXPROCS` = all available cores, so traffic bursts parallelize
instead of bottlenecking on one core.
--- - Python 3.9 or newer
- pip
- (Optional) virtual environment tool (venv)
## Build & run Python packages:
Everything builds and runs in Docker — **no host Go toolchain needed** (the - fastapi
multi-stage images compile from source). The production stack is the base compose - uvicorn
(databases, Grafana, Discord bot) plus two override files for the Go services and - pydantic
the cutover wiring. - pandas
- matplotlib
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/dereth-tracker.git
cd dereth-tracker
```
2. Create and activate a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install fastapi uvicorn pydantic pandas matplotlib
```
## Configuration
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
- The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
## Usage
Start the server using Uvicorn:
```bash ```bash
# --- build the Go service images --- uvicorn main:app --reload --host 0.0.0.0 --port 8000
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
# --- production: Go services in write mode, serving the site + ingest ---
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.go.yml` defines the Go services (plus the isolated shadow DBs used during the parallel run). - Live Map: `http://localhost:8000/`
- `docker-compose.cutover.yml` flips the Go services to **write mode** against the production DBs (`READ_ONLY=false`, `SKIP_SCHEMA_INIT=true` so they run no DDL and trust the existing schema) and points the Discord bot at the Go `/ws/live`. Drop this file to return the Go services to read-only parallel mode. - Analytics Dashboard: `http://localhost:8000/graphs.html`
- `BUILD_VERSION` is shown in the frontend sidebar (CalVer: `YYYY.M.D.HHMM-gitshorthash`).
- Required env (server `.env`, **never committed**): `SHARED_SECRET`, `SECRET_KEY`, `POSTGRES_PASSWORD`, `INVENTORY_DB_PASSWORD`, `DISCORD_ACLOG_WEBHOOK`, `DISCORD_RARE_BOT_TOKEN`, the Discord channel IDs, and Grafana admin. See `.env.example`.
### Frontend (unchanged by the migration) ## API Reference
The React app and the legacy static pages call the same absolute paths ### POST /position
(`/api/...`, `/inv/...`, `/live`, …) — the Go tracker answers them, so the Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
frontend ships as-is.
```bash **Request Body Example:**
cd frontend && npm run dev # local dev, port 5173, /api → :8770 ```json
bash deploy-frontend.sh # complete build + copy into static/ (runs npm run build itself) {
"character_name": "Dunking Rares",
"char_tag": "moss",
"session_id": "dunk-20250422-xyz",
"timestamp": "2025-04-22T13:45:00Z",
"ew": 123.4,
"ns": 567.8,
"z": 10.2,
"kills": 42,
"deaths": 1,
"rares_found": 2,
"prismatic_taper_count": 17,
"vt_state": "Combat",
"kills_per_hour": "N/A",
"onlinetime": "00:05:00"
}
``` ```
The tracker serves `static/` directly (bind-mounted), so static/JS/CSS changes ### GET /live
need no restart. ⚠️ `npm run build` writes to `static/_build/`; only Returns active players seen within the last 30 seconds:
`deploy-frontend.sh` copies it into the served `static/`.
### nginx ```json
{
"players": [ { ... } ]
}
```
The live config is host-side at `/etc/nginx/sites-enabled/overlord` (source copy ### GET /history
in `nginx/overlord.conf`); the `tracker_go` upstream is in Retrieve historical snapshots with optional `from` and `to` ISO8601 timestamps:
`/etc/nginx/conf.d/tracker_go.conf` (`server 127.0.0.1:8770;`). Production routes
`/`, `/api/`, `/websocket/` to the Go tracker. Every location that proxies to the
tracker **must** set `X-Forwarded-For` — it drives the internal-trust auth rule.
### Overlord Agent ```
GET /history?from=2025-04-22T12:00:00Z&to=2025-04-22T13:00:00Z
```
Unchanged by the migration — it's a host-side Python systemd service. Code change: Response:
`git pull && sudo systemctl restart overlord-agent`. Its env lives separately at
`/etc/overlord/agent.env`. See `agent/` and `CLAUDE.md`.
--- ```json
{
"data": [ { ... } ]
}
```
## WebSocket contract ## Frontend
- **`/ws/position`** — plugin → backend. Telemetry, vitals, inventory, portal, rare, combat, quest, chat, share_*, … Authenticated by the `X-Plugin-Secret` header against `SHARED_SECRET` (constant-time; fails closed when unset). The tracker forwards inventory to `inventory-go`, accumulates kill/combat stats, and re-broadcasts to browsers. - **Live Map**: `static/index.html` Real-time player positions on a map.
- **`/ws/live`** — browser ↔ backend. Session-cookie (or internal-trust) authenticated. Accepts `subscribe`, `request_dungeon_map`, and `{player_name, command}` envelopes routed to the matching plugin socket. **Telemetry is broadcast typeless** so the browser ignores it and takes player data from the 5 s `/live` poll (matching the original design — broadcasting it typed flaps the per-player counters). - **Analytics Dashboard**: `static/graphs.html` Interactive charts powered by [D3.js](https://d3js.org/).
- **Internal-trust rule:** a request skips cookie auth only when its source is private/loopback **and** carries no `X-Forwarded-For`. nginx sets XFF on all internet traffic, so only host-side / compose-network callers qualify.
### Payload note ## Database Schema
Payloads are snake_case JSON; keep field names and shapes stable across plugin + - **telemetry_log**: Stored history of snapshots.
backend. The plugin sends several numeric telemetry fields as **strings** - **live_state**: Current snapshot per character (upserted).
(`kills_per_hour`, `deaths`, `total_deaths`, `prismatic_taper_count`) — the backend
coerces them (`coerceNum` in `tracker-go/reads.go`).
## Auth & users ## Sample Payload
Session cookies are signed with `SECRET_KEY` via an itsdangerous-compatible See `test.json` for an example telemetry snapshot.
`URLSafeTimedSerializer` (HMAC-SHA1, 30-day expiry) — cookies interoperate with
the legacy Python service. Login at `/login` (bcrypt against the `users` table),
admin user CRUD at `/api-admin/users`, current user at `/me`.
## Databases ## Contributing
Two separate Postgres databases, both schema-from-code: Contributions are welcome! Feel free to open issues or submit pull requests.
- **`dereth`** (TimescaleDB, `dereth-db`): hypertables `telemetry_events` + `spawn_events`, plus `char_stats`, `combat_stats(_sessions)`, `rare_*`, `portals`, `character_stats`, `users`. Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only.
- **`inventory_db`** (postgres:14, `inventory-db`): 7 normalized tables (`items` + combat/requirements/enhancements/ratings/spells/raw_data).
In cutover mode the Go services reuse these production databases directly; the
shadow DBs in `docker-compose.go.yml` exist only for isolated parallel-run
validation. **Backups:** `pg_dump -Fc` of both DBs; TimescaleDB restore needs
`timescaledb_pre_restore()` / `post_restore()` around `pg_restore`.
## Route conventions
- nginx strips `/api/` before proxying, so backend routes do **not** start with `/api/`.
- Hyphenated routes (`/api-version`, `/api-admin/...`) deliberately bypass the strip (they fall through nginx's `location /`).
- The static SPA is the catch-all (`GET /`), registered after the API routes, with `index.html` fallback for client-side routing.
- `/inv/*` reverse-proxies to the inventory service; `/api/agent/*` is proxied by nginx (not the tracker) to the host-side agent.
## Operational notes
- Discord: the rare bot posts rares + relays allegiance chat; **death/idle alerts come from the tracker itself** via `DISCORD_ACLOG_WEBHOOK`.
- Issue board persists to the flat file `static/openissues.json` (web-served, mounted read-write).
- Logs: `docker logs dereth-tracker-go`, `docker logs inventory-go`. Read-only psql: `docker exec dereth-db psql -U postgres -d dereth`, `docker exec inventory-db psql -U inventory_user -d inventory_db`.
- **This repo is PUBLIC** on git.snakedesert.se — never commit secrets. `.env` is gitignored; `.env.example` is the template.
## Branches
- **`master`** — the Go production backend (this).
- **`python-legacy`** — the original Python/FastAPI implementation, preserved for reference and rollback.
See [`CLAUDE.md`](CLAUDE.md) for contributor/agent guidance and deeper internals.

View file

@ -1,146 +0,0 @@
# Overlord Agent
A small host-side Python service that gives Claude Code (running in
headless mode) access to live Overlord data so it can answer questions
from the dashboard chat window.
## Why a separate service?
`dereth-tracker` runs in Docker. The `claude` CLI binary at
`/home/erik/.local/bin/claude` depends on `~/.claude` credentials owned
by user `erik` on the host. The tracker container can't invoke it.
So this service runs **outside** Docker, listens on `127.0.0.1:8767`,
and nginx routes `/api/agent/*` to it. It validates the same browser
session cookie the tracker issues (shared `SECRET_KEY`) and shells out
to `claude -p` with `cwd=/home/erik/MosswartOverlord`.
## Architecture
```
Browser ──nginx──┬─► /api/* ──► dereth-tracker (Docker, 8765)
└─► /api/agent/* ──► overlord-agent (host, 8767)
├─► subprocess: claude -p ...
│ │
│ └─► MCP stdio ──► mcp_overlord.py
│ │
│ └─► HTTP loopback to tracker
│ └─► asyncpg to dereth-db
└─► validates "session" cookie
```
## Files
| File | What |
|------|------|
| `service.py` | FastAPI app (`/agent/health`, `/agent/sessions/new`, `/agent/ask`, `/agent/sessions/{id}/history`) |
| `auth.py` | Session-cookie validation (mirrors `main.py:1013-1019`) |
| `claude_wrapper.py` | `asyncio.create_subprocess_exec("claude", "-p", ...)` |
| `tools.py` | Pure tool implementations (HTTP loopback + read-only DB) |
| `mcp_overlord.py` | MCP stdio server registering tools for Claude Code |
| `sql/0001_overlord_agent_ro.sql` | Read-only PG role for the SQL tool |
| `overlord-agent.service` | systemd unit |
| `install.sh` | One-shot installer (venv + pip install + systemd) |
## Required env vars (in repo-root `.env`)
```
SECRET_KEY=<same value the tracker uses to sign cookies>
AGENT_DB_DSN=postgresql://overlord_agent_ro:<password>@127.0.0.1:5432/dereth
TRACKER_URL=http://127.0.0.1:8765 # optional, this is the default
CLAUDE_BIN=/home/erik/.local/bin/claude # optional, this is the default
CLAUDE_CWD=/home/erik/MosswartOverlord # optional, this is the default
CLAUDE_TIMEOUT_S=120 # optional
```
## First-time setup on the server
1. **Create the read-only DB role** (one-time):
```bash
docker exec -i dereth-db psql -U postgres -d dereth \
< /home/erik/MosswartOverlord/agent/sql/0001_overlord_agent_ro.sql
docker exec -it dereth-db psql -U postgres -d dereth \
-c "ALTER ROLE overlord_agent_ro PASSWORD '<random-password>';"
```
2. **Add `AGENT_DB_DSN`** to `/home/erik/MosswartOverlord/.env` with the
password you just set.
3. **Run the installer**:
```bash
cd /home/erik/MosswartOverlord
bash agent/install.sh
```
4. **Update nginx**: edit `/etc/nginx/sites-enabled/overlord` to add the
`/api/agent/` location (already in `nginx/overlord.conf` in the repo —
just `sudo cp` and reload).
## Day-to-day deploy
After editing any agent file:
```bash
# On dev:
git push
# On server:
ssh erik@overlord.snakedesert.se
cd /home/erik/MosswartOverlord
git pull
sudo systemctl restart overlord-agent
journalctl -u overlord-agent -f # tail logs
```
For Python dependency changes:
```bash
agent/.venv/bin/pip install -r agent/requirements.txt
sudo systemctl restart overlord-agent
```
## Smoke tests
```bash
# 1. Service alive?
curl http://127.0.0.1:8767/agent/health
# 2. Cookie required?
curl -X POST http://127.0.0.1:8767/agent/ask \
-H 'Content-Type: application/json' \
-d '{"session_id":"x","message":"hi"}'
# ⇒ 401
# 3. Direct claude invocation works?
echo "hello" | /home/erik/.local/bin/claude -p \
--session-id 11111111-1111-1111-1111-111111111111 \
--output-format json
# 4. End-to-end via nginx (with cookie):
curl -X POST https://overlord.snakedesert.se/api/agent/ask \
-b 'session=<your-session-cookie>' \
-H 'Content-Type: application/json' \
-d '{"session_id":"<uuid>","message":"How many characters are online?"}'
```
## Cost / rate-limit notes
- Each `/agent/ask` shells out to `claude -p` once.
- We use the user's Claude subscription (no API key) — flat-rate, no
per-call billing, but subscription-tier rate limits still apply.
- **Reactive only**: there are no background loops or periodic ticks.
Each user message = one Claude turn (which may chain several tool
calls internally before producing a final answer).
- The SQL tool is hard-capped at 10s and 200 rows.
- `suitbuilder_search` is the only tool that can take minutes; nginx
read timeout is 180s for `/api/agent/`.
## Adding a new MCP tool
1. Implement `async def my_tool(...) -> dict` in `tools.py`.
2. Register it in `mcp_overlord.py` under `TOOL_DEFS`:
- description (the agent reads this to decide when to call)
- JSON schema for arguments
- lambda dispatching to `T.my_tool(...)`
3. `sudo systemctl restart overlord-agent`. Claude Code re-discovers the
tool list on each invocation.

View file

@ -1,10 +0,0 @@
"""Overlord Agent — host-side service that shells out to claude -p.
Runs OUTSIDE the dereth-tracker Docker container because the `claude` CLI
binary lives at /home/erik/.local/bin/claude on the host and depends on
~/.claude/ credentials owned by user erik. The container can't invoke it
directly, so this is a small standalone FastAPI service on port 8767.
nginx routes /api/agent/* to here. The same browser session cookie that
dereth-tracker validates is reused (shared SECRET_KEY env var).
"""

View file

@ -1,58 +0,0 @@
"""Session-cookie validation that mirrors main.py.
Re-implements the verify path so this host-side service can authenticate
the same browser cookie that dereth-tracker issues. Both services must
share the SECRET_KEY env var.
"""
from __future__ import annotations
import os
from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
# Mirror main.py — and fail closed like it does: starting with a known
# default key would let anyone forge a valid session cookie.
SECRET_KEY = os.getenv("SECRET_KEY", "")
if not SECRET_KEY or SECRET_KEY == "change-me-in-production-please":
raise RuntimeError(
"SECRET_KEY env var must be set (shared with dereth-tracker; see "
"/etc/overlord/agent.env) — refusing to start with a forgeable "
"session-signing key"
)
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
_serializer = URLSafeTimedSerializer(SECRET_KEY)
def verify_session_cookie(token: str) -> dict | None:
"""Verify and decode a session token. Returns None if invalid/expired.
Mirrors main.py:1013-1019 byte-for-byte so a cookie issued by the tracker
decodes here identically.
"""
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE)
return {"username": data["u"], "is_admin": data["a"]}
except (BadSignature, SignatureExpired, KeyError):
return None
def require_user(request: Request) -> dict:
"""FastAPI dependency: enforces a valid session cookie.
Returns the decoded user dict on success; raises 401 otherwise.
"""
token = request.cookies.get("session")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user = verify_session_cookie(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session invalid or expired",
)
return user

View file

@ -1,280 +0,0 @@
"""Subprocess wrapper around `claude -p` (Claude Code in headless JSON mode).
Run from cwd=/home/erik/MosswartOverlord so:
Sessions persist at ~/.claude/projects/-home-erik-MosswartOverlord/<uuid>.jsonl
Project-level .mcp.json is auto-loaded
CLAUDE.md in the repo root briefs the agent
The `--session-id` flag both creates a new session (first call) and resumes
an existing one (subsequent calls), so we don't need separate code paths.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# These can be overridden via env vars for non-prod testing.
CLAUDE_BIN = os.getenv("CLAUDE_BIN", "/home/erik/.local/bin/claude")
CLAUDE_CWD = os.getenv("CLAUDE_CWD", "/home/erik/MosswartOverlord")
# Hard cap on how long a single agent turn may take. Claude Code can spin a
# while when chaining many tool calls; we don't want to leave a zombie
# subprocess if something gets stuck.
CLAUDE_TIMEOUT_S = int(os.getenv("CLAUDE_TIMEOUT_S", "240"))
@dataclass
class ClaudeResult:
result: str
session_id: str
duration_ms: int
num_turns: int
is_error: bool
raw: dict[str, Any]
class ClaudeError(RuntimeError):
"""Raised when the claude CLI returns a non-zero exit or unparseable output."""
def _session_exists(session_id: str) -> bool:
"""True if Claude Code has already persisted a JSONL for this session.
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
where non-alphanumerics in the cwd are replaced with hyphens.
"""
encoded = "".join(c if c.isalnum() else "-" for c in CLAUDE_CWD)
path = Path.home() / ".claude" / "projects" / encoded / f"{session_id}.jsonl"
return path.is_file()
async def ask_claude(message: str, session_id: str) -> ClaudeResult:
"""Send `message` to `claude -p` for `session_id`; return parsed result.
On the FIRST message of a session uses `--session-id <uuid>` to create it.
On subsequent messages uses `--resume <uuid>` because claude rejects
`--session-id` on existing sessions ("Session ID ... is already in use").
Raises ClaudeError on subprocess failure, JSON parse failure, or timeout.
"""
if not Path(CLAUDE_BIN).exists():
raise ClaudeError(f"claude binary not found at {CLAUDE_BIN}")
if not Path(CLAUDE_CWD).is_dir():
raise ClaudeError(f"CLAUDE_CWD does not exist: {CLAUDE_CWD}")
# Whitelist only our MCP tools so Claude Code can call them without
# human approval. Names follow the convention mcp__<server>__<tool>.
# We deliberately omit built-in tools (Bash, Write, Edit, Read, etc.)
# — the assistant doesn't need them for live-state Q&A and they'd be a
# security/permissions footgun on an unattended service.
allowed_tools = ",".join(
[
"mcp__overlord__get_live_players",
"mcp__overlord__get_recent_rares",
"mcp__overlord__query_telemetry_db",
"mcp__overlord__get_player_state",
"mcp__overlord__get_inventory",
"mcp__overlord__get_inventory_search",
"mcp__overlord__search_items",
"mcp__overlord__get_combat_stats",
"mcp__overlord__get_equipment_cantrips",
"mcp__overlord__get_quest_status",
"mcp__overlord__get_server_health",
"mcp__overlord__suitbuilder_search",
]
)
# CRITICAL: Claude Code's built-in meta-tools (ToolSearch, Monitor, etc.)
# bypass the --allowed-tools whitelist. They come from Anthropic's tool
# registry rather than from local MCP servers. We must explicitly DISALLOW
# them — confirmed by testing that ToolSearch was reachable even with
# `--permission-mode dontAsk` and a tight --allowed-tools list.
disallowed_tools = ",".join(
[
# File / shell / search built-ins (defense in depth — already not
# in allow list, but if someone toggles permission-mode this
# belt-and-suspenders the deny side).
"Bash",
"Write",
"Edit",
"Read",
"Glob",
"Grep",
"NotebookEdit",
# Network built-ins
"WebSearch",
"WebFetch", # blocked here; settings.json re-allows acpedia.org
# Subagent spawning — the assistant must NEVER delegate to a
# general-purpose subagent (which would have its own tool set).
"Agent",
# Tool / session meta-tools — these can list, load, or chain
# into other tools and must NOT be reachable.
"ToolSearch",
"Monitor",
"TaskOutput",
"TaskStop",
"TodoWrite",
"Skill",
"EnterPlanMode",
"ExitPlanMode",
"EnterWorktree",
"ExitWorktree",
"AskUserQuestion",
"ListMcpResourcesTool",
"ReadMcpResourceTool",
"PushNotification",
# Scheduling / cron — the agent must never schedule itself.
"CronCreate",
"CronList",
"CronDelete",
"ScheduleWakeup",
"RemoteTrigger",
# Anthropic first-party connectors from the user's claude.ai
# account. These are off-mission for an Overlord assistant and
# would leak personal data outside the game-state domain.
"mcp__claude_ai_Gmail__create_draft",
"mcp__claude_ai_Gmail__create_label",
"mcp__claude_ai_Gmail__get_message",
"mcp__claude_ai_Gmail__get_thread",
"mcp__claude_ai_Gmail__list_drafts",
"mcp__claude_ai_Gmail__list_labels",
"mcp__claude_ai_Gmail__label_message",
"mcp__claude_ai_Gmail__label_thread",
"mcp__claude_ai_Gmail__search_messages",
"mcp__claude_ai_Gmail__search_threads",
"mcp__claude_ai_Gmail__send_message",
"mcp__claude_ai_Gmail__unlabel_message",
"mcp__claude_ai_Gmail__unlabel_thread",
"mcp__claude_ai_Google_Calendar__authenticate",
"mcp__claude_ai_Google_Drive__authenticate",
]
)
# Pick --session-id (creates) vs --resume (continues) based on whether
# the session JSONL already exists on disk.
is_new = not _session_exists(session_id)
session_flag = "--session-id" if is_new else "--resume"
args = [
CLAUDE_BIN,
"-p",
session_flag,
session_id,
"--output-format",
"json",
"--allowed-tools",
allowed_tools,
# Built-in meta-tools that --allowed-tools does NOT block — must
# be explicitly listed here.
"--disallowed-tools",
disallowed_tools,
# CRITICAL: dontAsk auto-DENIES anything outside --allowed-tools.
# Do NOT use bypassPermissions here — that mode ignores the whitelist
# entirely and lets the model call Bash/Write/Edit/etc. (verified
# the hard way: it wrote /tmp/owned.sh when prompted to).
# See https://code.claude.com/docs/en/permission-modes.md
"--permission-mode",
"dontAsk",
]
logger.info(
"claude exec: session=%s mode=%s msg_len=%d cwd=%s",
session_id,
"new" if is_new else "resume",
len(message),
CLAUDE_CWD,
)
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=CLAUDE_CWD,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=message.encode("utf-8")),
timeout=CLAUDE_TIMEOUT_S,
)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
raise ClaudeError(f"claude timed out after {CLAUDE_TIMEOUT_S}s")
if proc.returncode != 0:
stderr_text = stderr.decode("utf-8", "replace")
# If we picked the wrong flag (e.g. JSONL deleted from disk between
# our check and exec, or a never-flushed session), claude prints
# "Session ID … is already in use." Re-issue with --resume.
if is_new and "already in use" in stderr_text:
logger.info("session %s actually exists; retrying with --resume", session_id)
args2 = list(args)
args2[2] = "--resume"
proc2 = await asyncio.create_subprocess_exec(
*args2,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=CLAUDE_CWD,
)
try:
stdout, stderr = await asyncio.wait_for(
proc2.communicate(input=message.encode("utf-8")),
timeout=CLAUDE_TIMEOUT_S,
)
except asyncio.TimeoutError:
try:
proc2.kill()
except ProcessLookupError:
pass
raise ClaudeError(f"claude timed out after {CLAUDE_TIMEOUT_S}s")
if proc2.returncode != 0:
raise ClaudeError(
f"claude exited {proc2.returncode} after retry: "
f"{stderr.decode('utf-8', 'replace')[:500]}"
)
else:
raise ClaudeError(
f"claude exited {proc.returncode}: {stderr_text[:500]}"
)
raw_text = stdout.decode("utf-8", "replace").strip()
if not raw_text:
raise ClaudeError("claude produced empty stdout")
# In --output-format json mode the LAST line is the JSON envelope; some
# earlier lines may be progress. Be tolerant.
try:
envelope = json.loads(raw_text)
except json.JSONDecodeError:
# Try the last non-empty line
last = next(
(line for line in reversed(raw_text.splitlines()) if line.strip()),
"",
)
try:
envelope = json.loads(last)
except json.JSONDecodeError as e:
raise ClaudeError(
f"claude stdout was not JSON: {raw_text[:500]}"
) from e
return ClaudeResult(
result=envelope.get("result", ""),
session_id=envelope.get("session_id", session_id),
duration_ms=int(envelope.get("duration_ms", 0)),
num_turns=int(envelope.get("num_turns", 0)),
is_error=bool(envelope.get("is_error", False)),
raw=envelope,
)

View file

@ -1,46 +0,0 @@
#!/bin/bash
# Install / re-install the Overlord Agent host-side service.
#
# Run as user `erik` from /home/erik/MosswartOverlord:
# bash agent/install.sh
#
# Requires sudo for the systemd parts (you'll be prompted once).
set -euo pipefail
REPO_DIR="/home/erik/MosswartOverlord"
AGENT_DIR="$REPO_DIR/agent"
VENV_DIR="$AGENT_DIR/.venv"
SERVICE_FILE="$AGENT_DIR/overlord-agent.service"
SYSTEMD_TARGET="/etc/systemd/system/overlord-agent.service"
if [[ "$(pwd)" != "$REPO_DIR" ]]; then
echo "Run from $REPO_DIR (currently in $(pwd))" >&2
exit 1
fi
echo "==> Creating/updating venv at $VENV_DIR"
if [[ ! -d "$VENV_DIR" ]]; then
python3 -m venv "$VENV_DIR"
fi
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
"$VENV_DIR/bin/pip" install --quiet -r "$AGENT_DIR/requirements.txt"
echo "==> Installing systemd unit"
sudo cp "$SERVICE_FILE" "$SYSTEMD_TARGET"
sudo systemctl daemon-reload
echo "==> Enabling + starting overlord-agent"
sudo systemctl enable overlord-agent
sudo systemctl restart overlord-agent
sleep 1
echo "==> Status:"
sudo systemctl --no-pager status overlord-agent | head -15
echo ""
echo "==> Smoke test:"
curl -s http://127.0.0.1:8767/agent/health | python3 -m json.tool || true
echo ""
echo "Done. Logs: journalctl -u overlord-agent -f"

View file

@ -1,293 +0,0 @@
"""MCP stdio server exposing Overlord data to Claude Code.
Configured via .mcp.json at the repo root, which Claude Code auto-loads
when invoked with cwd=/home/erik/MosswartOverlord. Tool implementations
live in tools.py this file is just MCP protocol plumbing.
Run directly with:
python3 /home/erik/MosswartOverlord/agent/mcp_overlord.py
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
from . import tools as T
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s mcp_overlord: %(message)s",
)
logger = logging.getLogger("mcp_overlord")
server: Server = Server("overlord")
# ─── Tool registry ──────────────────────────────────────────────────
#
# Each entry: name → (description, JSON schema, callable async fn).
# We register them with @server.list_tools / @server.call_tool below.
TOOL_DEFS: dict[str, dict[str, Any]] = {
"get_live_players": {
"description": (
"Return active characters seen in the last ~30 seconds with their "
"current position, kills, KPH, vitae, online time, and VTank state. "
"Use this for any 'who is online right now / what is X doing' question."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_live_players(),
},
"get_recent_rares": {
"description": (
"Return rare item finds from the last N hours, newest first. "
"Use for questions about recent drops, who is finding rares, or "
"rare-rate analysis. Defaults to 24 hours, max 30 days."
),
"schema": {
"type": "object",
"properties": {
"hours": {
"type": "integer",
"minimum": 1,
"maximum": 720,
"default": 24,
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 100,
},
},
},
"fn": lambda args: T.get_recent_rares(
hours=int(args.get("hours", 24)),
limit=int(args.get("limit", 100)),
),
},
"query_telemetry_db": {
"description": (
"Run a read-only SQL query against the telemetry database (TimescaleDB). "
"Only SELECT / WITH statements are accepted; any DML or DDL is rejected. "
"Useful for questions that aren't covered by the other tools — top-N "
"lists, custom aggregations, time-window comparisons. "
"Available tables include: telemetry_events (hypertable, 30d retention), "
"rare_events, spawn_events (hypertable, 7d retention), portals, "
"char_stats, rare_stats, rare_stats_sessions, character_stats, "
"combat_stats, combat_stats_sessions, server_status. "
"The query has a 10s timeout and returns at most 200 rows."
),
"schema": {
"type": "object",
"required": ["sql"],
"properties": {
"sql": {
"type": "string",
"description": "A single PostgreSQL SELECT or WITH ... SELECT statement.",
}
},
},
"fn": lambda args: T.query_telemetry_db(str(args["sql"])),
},
"get_player_state": {
"description": (
"Combined snapshot for ONE character: live telemetry (if online) "
"+ full character stats (attributes, skills, augmentations). "
"Use this for questions like 'what is X doing right now' or 'show me X's stats'."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {
"character_name": {"type": "string"},
},
},
"fn": lambda args: T.get_player_state(str(args["character_name"])),
},
"get_inventory": {
"description": (
"Full inventory listing for one character — every item with name, "
"icon, container, equipped slot, spells, material, tinkers, etc. "
"Large response — prefer get_inventory_search for narrow queries."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_inventory(str(args["character_name"])),
},
"get_inventory_search": {
"description": (
"Filtered inventory search for ONE character. Use search_items "
"instead when the user wants to find something across ALL chars."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {
"character_name": {"type": "string"},
"filters": {
"type": "object",
"description": "Query params dict, e.g. {\"name\": \"pearl\", \"armor_level_min\": 500}",
},
},
},
"fn": lambda args: T.get_inventory_search(
str(args["character_name"]), args.get("filters") or {}
),
},
"search_items": {
"description": (
"CROSS-CHARACTER item search — one query that scans every "
"character's inventory. Use this whenever the user asks "
"'find me an X on any of my chars'. **Do not** iterate "
"get_inventory per character — this single tool call is far "
"faster and avoids agent timeouts.\n\n"
"Filter keys (pass as `filters` object, all optional):\n"
" include_all_characters: true (default if no scope given)\n"
" character: 'Name' (single char)\n"
" characters: 'A,B,C' (specific list, comma-separated)\n"
" text: substring of item name/description\n"
" has_spell: 'Legendary Acid Ward' (exact spell name match)\n"
" spell_contains: 'Legendary' (substring)\n"
" legendary_cantrips: 'Foo,Bar'\n"
" equipment_status: 'equipped' | 'unequipped'\n"
" equipment_slot: int bitmask (4=chest, 2048=bracelet, 4096=ring)\n"
" slot_names: 'Bracelet,Ring'\n"
" armor_only / jewelry_only / weapon_only: bool\n"
" min_armor / max_armor / min_damage / max_damage: int\n"
),
"schema": {
"type": "object",
"required": ["filters"],
"properties": {
"filters": {
"type": "object",
"description": "Query params dict — see tool description for keys.",
},
},
},
"fn": lambda args: T.search_items_global(args.get("filters") or {}),
},
"get_combat_stats": {
"description": (
"Lifetime + session combat stats for one character. Includes total "
"damage given/received, per-element offense/defense breakdown, kill "
"counts, and aetheria surge counts."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_combat_stats(str(args["character_name"])),
},
"get_equipment_cantrips": {
"description": (
"Currently-equipped items for a character along with their active "
"cantrip/spell state. Useful for 'what is X wearing' or 'is X "
"running their suit' questions."
),
"schema": {
"type": "object",
"required": ["character_name"],
"properties": {"character_name": {"type": "string"}},
},
"fn": lambda args: T.get_equipment_cantrips(str(args["character_name"])),
},
"get_quest_status": {
"description": (
"Active quest timers and progress across ALL characters. Returns "
"for each character which quests are READY vs counting down."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_quest_status(),
},
"get_server_health": {
"description": (
"Current Coldeve game-server status: up/down, latency in ms, "
"current player count from TreeStats.net, total uptime. Updated "
"every 30 seconds in the background."
),
"schema": {"type": "object", "properties": {}},
"fn": lambda _args: T.get_server_health(),
},
"suitbuilder_search": {
"description": (
"Run a constraint-satisfaction armor optimization across all "
"characters' inventories ('mules'). Drives the same suitbuilder "
"the /suitbuilder.html page uses. Pass the same params dict the "
"page sends — see /suitbuilder.html JS for the schema. The search "
"is SSE-streaming on the backend; this tool collects until done "
"and returns the final suit(s) plus the last few phase events. "
"Can take up to 5 minutes for complex constraints — only call "
"when the user explicitly asks for an optimization run."
),
"schema": {
"type": "object",
"required": ["params"],
"properties": {
"params": {
"type": "object",
"description": "Suitbuilder request body (characters, locked slots, set constraints, etc.)",
},
},
},
"fn": lambda args: T.suitbuilder_search(args.get("params") or {}),
},
}
# ─── MCP protocol wiring ────────────────────────────────────────────
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(name=name, description=defn["description"], inputSchema=defn["schema"])
for name, defn in TOOL_DEFS.items()
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
if name not in TOOL_DEFS:
return [TextContent(type="text", text=f"unknown tool: {name}")]
fn = TOOL_DEFS[name]["fn"]
try:
result = await fn(arguments or {})
except T.SqlNotAllowed as e:
return [TextContent(type="text", text=f"REJECTED: {e}")]
except Exception as e: # noqa: BLE001
logger.exception("tool %s failed", name)
return [TextContent(type="text", text=f"ERROR: {type(e).__name__}: {e}")]
text = json.dumps(result, default=str, ensure_ascii=False, indent=2)
return [TextContent(type="text", text=text)]
async def _run() -> None:
logger.info("starting MCP stdio server (overlord)")
try:
async with stdio_server() as (reader, writer):
await server.run(reader, writer, server.create_initialization_options())
finally:
await T.shutdown()
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()

View file

@ -1,115 +0,0 @@
[Unit]
Description=Overlord Agent (Claude Code shell-out service)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Dedicated unprivileged user — kernel-level isolation from `erik`.
# overlord-agent has NO access to /home/erik/.claude (mode 0700),
# /home/erik/.ssh, /home/erik/.bash_history, /home/erik/.gitconfig, etc.
# Its own claude state lives at /var/lib/overlord-agent/.claude/ and its
# claude session JSONLs land there — completely separate from any
# interactive Claude Code use by the human user.
User=overlord-agent
Group=overlord-agent
# Working directory: the repo root (group-readable to overlord-agent).
# claude session JSONLs path-encode this cwd so it's important to keep
# stable across restarts.
WorkingDirectory=/home/erik/MosswartOverlord
# HOME explicitly set so claude reads /var/lib/overlord-agent/.claude/*
# instead of trying /home/erik/.claude/* (which is now 0700, locked out).
Environment="HOME=/var/lib/overlord-agent"
# Secrets file (root:overlord-agent 0640). REQUIRED (no leading '-'):
# a missing secrets file must abort startup, not fail open — auth.py also
# refuses to start without SECRET_KEY.
EnvironmentFile=/etc/overlord/agent.env
# Run inside the venv populated by install.sh.
ExecStart=/home/erik/MosswartOverlord/agent/.venv/bin/python -m agent.service
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
# ─── Resource caps ─────────────────────────────────────────────────
MemoryMax=512M
CPUQuota=200%
TasksMax=128
# ─── Filesystem hardening ──────────────────────────────────────────
# /usr, /boot, /efi become read-only; /etc + /var get a writable overlay
# that's discarded on stop. Subprocesses inherit these protections.
ProtectSystem=strict
ProtectHome=read-only
# Allow writing only to the explicit paths claude / our service need.
# - ~/.claude — session JSONL files
# - .venv pycache — minor pip cache writes
ReadWritePaths=/var/lib/overlord-agent/.claude
ReadWritePaths=/home/erik/MosswartOverlord/agent/.venv
ReadWritePaths=/var/log/overlord-agent
# StateDirectory creates/owns /var/lib/overlord-agent automatically.
StateDirectory=overlord-agent
LogsDirectory=overlord-agent
LogsDirectoryMode=0755
PrivateTmp=true
PrivateDevices=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
# Hide sensitive host paths even if something in the python or claude
# subprocess tree tries to read them.
InaccessiblePaths=/etc/shadow
InaccessiblePaths=/etc/gshadow
InaccessiblePaths=/etc/ssh
InaccessiblePaths=/root
InaccessiblePaths=-/home/erik/.ssh
InaccessiblePaths=-/home/erik/.bash_history
InaccessiblePaths=-/home/erik/.zsh_history
# ─── Privilege & capability hardening ──────────────────────────────
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
# MemoryDenyWriteExecute would break Node.js (V8 JIT requires W^X
# transitions via mprotect with PROT_EXEC on JITted code pages). Claude
# Code is a Node app, so omit this. Without JIT we'd lose all model
# performance. The other restrictions still prevent shellcode injection
# in practice (no Bash/Write tools, no shellcraft surface).
# MemoryDenyWriteExecute=true ← DO NOT enable; breaks Node V8 JIT
RestrictNamespaces=true
# ─── Network family restriction ────────────────────────────────────
# Block raw/packet sockets so even a kernel-LPE-class bug can't sniff
# traffic or forge packets. We don't IPAddressAllow-restrict because
# Anthropic's Cloudflare IPs shift and the whitelist would break claude.
# If you need true egress filtering, run nftables scoped to this
# service's cgroup — that's reliable in a way IPAddressAllow isn't.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# ─── Syscall filter ────────────────────────────────────────────────
# Use the standard @system-service preset which is what almost every
# hardened systemd unit uses. It already excludes the dangerous groups
# (privileged, mount, reboot, raw-io, etc.) by NOT including them, while
# being broad enough to host typical apps including Node.js.
#
# We tried adding extra "~@..." negations on top — they killed Claude
# (Node) with SIGSYS during startup. The default @system-service preset
# is the right balance; the rest of the hardening covers what we need.
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
SystemCallFilter=~@reboot
SystemCallFilter=~@mount
[Install]
WantedBy=multi-user.target

View file

@ -1,13 +0,0 @@
fastapi>=0.110
uvicorn[standard]>=0.30
httpx>=0.27
itsdangerous>=2.2
pydantic>=2.6
# MCP server SDK (used by mcp_overlord.py for the stdio MCP server)
mcp>=1.0
# SQL safety: parses SQL to enforce read-only on the query_db tool
sqlglot>=25.0
# Direct DB access for the read-only query tool and rare_events lookups
asyncpg>=0.29
# .env loader
python-dotenv>=1.0

View file

@ -1,347 +0,0 @@
"""Overlord Agent host-side FastAPI service.
Runs OUTSIDE Docker (host-side) on port 8767.
Endpoints:
GET /agent/health liveness check
POST /agent/sessions/new returns a fresh session UUID
POST /agent/ask runs claude -p with given session
GET /agent/sessions/{session_id}/history
replays a session's JSONL on disk
Auth: every endpoint except /health requires the same browser session
cookie that dereth-tracker issues.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from collections import deque
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from . import auth
from .claude_wrapper import CLAUDE_CWD, ClaudeError, ask_claude
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("agent")
# Audit log — every /agent/ask request gets a JSONL line here, separate
# from journald so the operator can grep without root. Set to /dev/null
# to disable. Rotated externally (logrotate) if it gets big.
AUDIT_LOG_PATH = Path(os.getenv("AGENT_AUDIT_LOG", "/var/log/overlord-agent/audit.jsonl"))
audit_logger = logging.getLogger("agent.audit")
try:
AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
_h = logging.FileHandler(AUDIT_LOG_PATH)
_h.setFormatter(logging.Formatter("%(message)s"))
audit_logger.addHandler(_h)
audit_logger.propagate = False
audit_logger.setLevel(logging.INFO)
except OSError as e:
logger.warning("audit log path %s not writable (%s); logging only via journal", AUDIT_LOG_PATH, e)
# Rate limit: per-user count over a rolling window. Defaults are generous
# for a single human at a keyboard but block automated abuse.
RATE_LIMIT_WINDOW_S = int(os.getenv("AGENT_RATE_WINDOW_S", "3600"))
RATE_LIMIT_MAX = int(os.getenv("AGENT_RATE_MAX", "60"))
# Per-user concurrent request cap (no fanning out 50 calls in parallel).
CONCURRENCY_LIMIT_PER_USER = int(os.getenv("AGENT_CONCURRENCY_PER_USER", "1"))
# Rolling timestamps of recent /agent/ask calls per user.
_rate_state: dict[str, deque[float]] = {}
# Per-user semaphores so a single user can't run multiple concurrent claude
# subprocesses (each is expensive).
_user_semaphores: dict[str, asyncio.Semaphore] = {}
def _check_rate_limit(username: str) -> tuple[bool, int]:
"""Return (allowed, retry_after_seconds)."""
now = time.monotonic()
window = _rate_state.setdefault(username, deque())
cutoff = now - RATE_LIMIT_WINDOW_S
while window and window[0] < cutoff:
window.popleft()
if len(window) >= RATE_LIMIT_MAX:
retry_after = int(window[0] + RATE_LIMIT_WINDOW_S - now) + 1
return False, retry_after
window.append(now)
return True, 0
def _user_semaphore(username: str) -> asyncio.Semaphore:
sem = _user_semaphores.get(username)
if sem is None:
sem = asyncio.Semaphore(CONCURRENCY_LIMIT_PER_USER)
_user_semaphores[username] = sem
return sem
def _audit(event: dict[str, Any]) -> None:
"""Emit one JSONL line to the audit log."""
event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
try:
audit_logger.info(json.dumps(event, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
app = FastAPI(title="Overlord Agent", version="0.1.0")
# ─── Models ──────────────────────────────────────────────────────────
class AskRequest(BaseModel):
session_id: str = Field(
..., description="Stable per-conversation UUID stored in browser localStorage"
)
message: str = Field(..., min_length=1, max_length=10_000)
class AskResponse(BaseModel):
result: str
session_id: str
duration_ms: int
num_turns: int
is_error: bool
class NewSessionResponse(BaseModel):
session_id: str
# ─── Helpers ─────────────────────────────────────────────────────────
def _encode_cwd(cwd: str) -> str:
"""Match Claude Code's on-disk encoding for cwd → directory name.
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
where non-alphanumerics in the cwd are replaced with hyphens.
Example: /home/erik/MosswartOverlord -home-erik-MosswartOverlord
"""
return "".join(c if c.isalnum() else "-" for c in cwd)
def _sessions_dir() -> Path:
return Path.home() / ".claude" / "projects" / _encode_cwd(CLAUDE_CWD)
# ─── Endpoints ───────────────────────────────────────────────────────
@app.get("/agent/health")
async def health() -> dict:
"""Liveness probe — no auth, used by deployment scripts."""
return {
"status": "ok",
"claude_cwd": CLAUDE_CWD,
"sessions_dir_exists": _sessions_dir().exists(),
}
@app.post("/agent/sessions/new", response_model=NewSessionResponse)
async def new_session(_user: dict = Depends(auth.require_user)) -> NewSessionResponse:
"""Generate a fresh session UUID. Doesn't touch disk — claude creates the
JSONL file when the first message lands."""
return NewSessionResponse(session_id=str(uuid.uuid4()))
@app.post("/agent/ask", response_model=AskResponse)
async def agent_ask(
req: AskRequest, user: dict = Depends(auth.require_user)
) -> AskResponse:
"""Forward a message to claude -p resuming the given session.
Enforces:
* Per-user rate limit (60 requests/hour by default).
* Per-user concurrency cap (1 in-flight at a time by default).
* Audit log of every request (JSONL).
"""
username = user["username"]
# Rate limit BEFORE acquiring the user semaphore — cheaper to reject.
allowed, retry_after = _check_rate_limit(username)
if not allowed:
_audit(
{
"event": "rate_limited",
"user": username,
"session_id": req.session_id,
"retry_after_s": retry_after,
}
)
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded; retry in {retry_after}s",
headers={"Retry-After": str(retry_after)},
)
sem = _user_semaphore(username)
if sem.locked():
_audit(
{
"event": "concurrency_blocked",
"user": username,
"session_id": req.session_id,
}
)
raise HTTPException(
status_code=429, detail="A previous question is still being processed"
)
started = time.monotonic()
async with sem:
_audit(
{
"event": "ask_start",
"user": username,
"session_id": req.session_id,
"message": req.message[:500],
"message_len": len(req.message),
}
)
try:
result = await ask_claude(req.message, req.session_id)
except ClaudeError as e:
elapsed_ms = int((time.monotonic() - started) * 1000)
logger.warning(
"claude failed user=%s session=%s err=%s", username, req.session_id, e
)
_audit(
{
"event": "ask_error",
"user": username,
"session_id": req.session_id,
"error": str(e)[:500],
"elapsed_ms": elapsed_ms,
}
)
raise HTTPException(status_code=502, detail=str(e))
elapsed_ms = int((time.monotonic() - started) * 1000)
logger.info(
"ask user=%s session=%s turns=%d duration_ms=%d (subprocess=%dms)",
username,
result.session_id,
result.num_turns,
elapsed_ms,
result.duration_ms,
)
_audit(
{
"event": "ask_ok",
"user": username,
"session_id": result.session_id,
"result_preview": (result.result or "")[:300],
"result_len": len(result.result or ""),
"turns": result.num_turns,
"elapsed_ms": elapsed_ms,
"subprocess_ms": result.duration_ms,
"is_error": result.is_error,
}
)
return AskResponse(
result=result.result,
session_id=result.session_id,
duration_ms=result.duration_ms,
num_turns=result.num_turns,
is_error=result.is_error,
)
@app.get("/agent/sessions/{session_id}/history")
async def session_history(
session_id: str, _user: dict = Depends(auth.require_user)
) -> JSONResponse:
"""Replay a session's JSONL from ~/.claude/projects/.../<id>.jsonl.
Returns a flat array of {role, text, timestamp} for the chat window.
Returns an empty array if the session file doesn't exist yet.
"""
# UUID sanity check to prevent path traversal — claude Code uses uuid4
try:
uuid.UUID(session_id)
except ValueError:
raise HTTPException(status_code=400, detail="invalid session_id")
path = _sessions_dir() / f"{session_id}.jsonl"
if not path.is_file():
return JSONResponse({"messages": []})
messages: list[dict[str, Any]] = []
try:
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# Claude Code records turns with type=user / type=assistant.
# Tool-use traffic is verbose; skip it for the chat UI.
msg_type = obj.get("type")
if msg_type not in ("user", "assistant"):
continue
msg = obj.get("message") or {}
content = msg.get("content")
# `content` may be a string or list[{type,text}].
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
else:
text = ""
if not text:
continue
messages.append(
{
"role": msg_type,
"text": text,
"timestamp": obj.get("timestamp"),
}
)
except OSError as e:
logger.warning("failed to read session %s: %s", session_id, e)
raise HTTPException(status_code=500, detail="failed to read session")
return JSONResponse({"messages": messages})
# ─── Entrypoint ──────────────────────────────────────────────────────
def main() -> None:
"""Run via `python -m agent.service` for local testing."""
import uvicorn
uvicorn.run(
"agent.service:app",
host="127.0.0.1",
port=8767,
log_level="info",
)
if __name__ == "__main__":
main()

View file

@ -1,35 +0,0 @@
-- Read-only PG role for the Overlord Agent's `query_telemetry_db` MCP tool.
--
-- This is the second line of defense (the first is the sqlglot parser in
-- agent/tools.py:assert_read_only). Even a parser bypass cannot mutate
-- because this role only has SELECT.
--
-- Apply on the dereth-db container:
-- docker exec dereth-db psql -U postgres -d dereth -f - < agent/sql/0001_overlord_agent_ro.sql
-- (substitute the password before running, or keep as a placeholder and
-- ALTER ROLE … PASSWORD '…' separately)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'overlord_agent_ro') THEN
CREATE ROLE overlord_agent_ro NOINHERIT LOGIN PASSWORD 'change-me-set-via-alter-role';
END IF;
END$$;
GRANT CONNECT ON DATABASE dereth TO overlord_agent_ro;
GRANT USAGE ON SCHEMA public TO overlord_agent_ro;
-- Grant SELECT on all current public tables.
GRANT SELECT ON ALL TABLES IN SCHEMA public TO overlord_agent_ro;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO overlord_agent_ro;
-- And on any future tables created in public.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO overlord_agent_ro;
-- TimescaleDB-internal schema (chunks live here). Read on hypertable chunks
-- requires SELECT on _timescaledb_internal too.
GRANT USAGE ON SCHEMA _timescaledb_internal TO overlord_agent_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA _timescaledb_internal TO overlord_agent_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA _timescaledb_internal
GRANT SELECT ON TABLES TO overlord_agent_ro;

View file

@ -1,451 +0,0 @@
"""Tool implementations exposed to Claude via the MCP server.
These are pure functions the MCP server (mcp_overlord.py) only handles
the protocol wrapping. Keep tool logic here so it's easy to test in
isolation and reuse from elsewhere (e.g. /agent/ask shortcuts).
Two flavors of data access:
* HTTP loopback to the dereth-tracker container (for endpoints that
already exist and have validated logic).
* Direct asyncpg to the read-only PG role for ad-hoc queries
(rare_events, telemetry, anything not exposed via HTTP).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
from urllib.parse import quote
import asyncpg
import httpx
import sqlglot
import sqlglot.errors
import sqlglot.expressions as exp
logger = logging.getLogger(__name__)
# The dereth-tracker FastAPI app, reachable from the host because Docker
# port-forwards 127.0.0.1:8765:8765 in docker-compose.yml.
TRACKER_URL = os.getenv("TRACKER_URL", "http://127.0.0.1:8765")
# Read-only PG role; see deployment plan.
DB_DSN = os.getenv(
"AGENT_DB_DSN",
"postgresql://overlord_agent_ro@127.0.0.1:5432/dereth",
)
# Hard caps for the SQL tool to keep the agent honest.
SQL_TIMEOUT_S = float(os.getenv("AGENT_SQL_TIMEOUT_S", "10"))
SQL_MAX_ROWS = int(os.getenv("AGENT_SQL_MAX_ROWS", "200"))
# ─── HTTP loopback helpers ──────────────────────────────────────────
_http_client: httpx.AsyncClient | None = None
async def _http() -> httpx.AsyncClient:
"""Lazily create + reuse a single httpx client (connection pool)."""
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(base_url=TRACKER_URL, timeout=30.0)
return _http_client
async def _get_json(path: str) -> Any:
client = await _http()
resp = await client.get(path)
resp.raise_for_status()
return resp.json()
# ─── DB helpers ─────────────────────────────────────────────────────
_db_pool: asyncpg.Pool | None = None
async def _db() -> asyncpg.Pool:
global _db_pool
if _db_pool is None:
_db_pool = await asyncpg.create_pool(
DB_DSN, min_size=1, max_size=4, command_timeout=SQL_TIMEOUT_S
)
return _db_pool
# ─── SQL safety ─────────────────────────────────────────────────────
_ALLOWED_TOPLEVEL = tuple(
cls for cls in (
getattr(exp, "Select", None),
getattr(exp, "With", None),
getattr(exp, "Union", None),
getattr(exp, "Subquery", None),
getattr(exp, "Intersect", None),
getattr(exp, "Except", None),
)
if cls is not None
)
class SqlNotAllowed(ValueError):
"""Raised when the agent attempts a non-read-only SQL statement."""
def assert_read_only(sql: str) -> None:
"""Parse `sql` and reject anything that isn't a read query.
Belt-and-suspenders: the PG role is also read-only (GRANT SELECT only),
so even a parser bypass can't actually mutate. This is the first line
of defense friendlier error messages and faster reject.
"""
try:
statements = sqlglot.parse(sql, read="postgres")
except sqlglot.errors.ParseError as e:
raise SqlNotAllowed(f"SQL parse error: {e}") from e
if not statements:
raise SqlNotAllowed("empty SQL")
if len(statements) > 1:
raise SqlNotAllowed("only one statement allowed")
stmt = statements[0]
if stmt is None:
raise SqlNotAllowed("empty parse result")
if not isinstance(stmt, _ALLOWED_TOPLEVEL):
raise SqlNotAllowed(
f"only SELECT / WITH allowed, got {type(stmt).__name__}"
)
# Walk the tree and reject any DML/DDL hidden inside (e.g. CTE with
# INSERT — yes, postgres allows that). Use getattr so version drift
# in sqlglot (renamed classes like AlterTable→Alter) doesn't crash
# the whole tool.
_DENY_NAMES = (
"Insert", "Update", "Delete", "Drop", "Create", "Merge",
"Alter", "AlterTable", "AlterColumn", "AlterDatabase",
"Truncate", "TruncateTable",
"Grant", "Revoke",
"Copy", # PostgreSQL COPY can write files
)
deny_classes = tuple(
cls for cls in (getattr(exp, name, None) for name in _DENY_NAMES)
if cls is not None
)
for node in stmt.walk():
# walk() returns the node, then in some sqlglot versions a tuple of
# (node, parent, key). Normalize.
actual = node[0] if isinstance(node, tuple) else node
if isinstance(actual, deny_classes):
raise SqlNotAllowed(
f"writes/DDL not allowed (found {type(actual).__name__})"
)
# ─── Tools ──────────────────────────────────────────────────────────
async def get_live_players() -> dict[str, Any]:
"""Active characters (telemetry seen in the last ~30s).
Returns the same shape as `GET /live`:
{ "players": [ { character_name, ew, ns, z, kills, ... } ] }
"""
return await _get_json("/live")
async def get_recent_rares(hours: int = 24, limit: int = 100) -> dict[str, Any]:
"""Rare item finds in the last N hours, newest first."""
hours = max(1, min(int(hours), 24 * 30)) # cap at 30 days
limit = max(1, min(int(limit), SQL_MAX_ROWS))
pool = await _db()
rows = await pool.fetch(
"""
SELECT timestamp, character_name, name, ew, ns, z
FROM rare_events
WHERE timestamp >= NOW() - ($1::int || ' hours')::interval
ORDER BY timestamp DESC
LIMIT $2
""",
hours,
limit,
)
return {
"hours": hours,
"count": len(rows),
"rares": [
{
"timestamp": r["timestamp"].isoformat(),
"character_name": r["character_name"],
"name": r["name"],
"ew": r["ew"],
"ns": r["ns"],
"z": r["z"],
}
for r in rows
],
}
async def query_telemetry_db(sql: str) -> dict[str, Any]:
"""Run a read-only SQL statement against the telemetry DB.
The query is parsed and any non-SELECT/WITH statement is rejected.
The connection role is also GRANT SELECT only (defense in depth).
Useful for ad-hoc questions: "top 5 KPH today", "kill count by character
yesterday", etc.
"""
assert_read_only(sql)
pool = await _db()
try:
rows = await asyncio.wait_for(pool.fetch(sql), timeout=SQL_TIMEOUT_S)
except asyncio.TimeoutError:
raise SqlNotAllowed(f"query exceeded {SQL_TIMEOUT_S:.0f}s timeout")
if len(rows) > SQL_MAX_ROWS:
rows = rows[:SQL_MAX_ROWS]
truncated = True
else:
truncated = False
return {
"row_count": len(rows),
"truncated": truncated,
"rows": [
{k: _json_safe(v) for k, v in dict(r).items()} for r in rows
],
}
def _json_safe(v: Any) -> Any:
"""Convert datetime / Decimal / etc. to JSON-friendly types."""
from datetime import date, datetime, timedelta
from decimal import Decimal
if v is None:
return None
if isinstance(v, (str, int, float, bool)):
return v
if isinstance(v, (datetime, date)):
return v.isoformat()
if isinstance(v, timedelta):
return v.total_seconds()
if isinstance(v, Decimal):
return float(v)
if isinstance(v, (list, tuple)):
return [_json_safe(x) for x in v]
if isinstance(v, dict):
return {k: _json_safe(x) for k, x in v.items()}
return str(v)
# ─── Per-character lookups (HTTP loopback) ──────────────────────────
async def get_player_state(character_name: str) -> dict[str, Any]:
"""Combined snapshot for one character: live telemetry + character stats.
Returns:
{
"character_name": str,
"telemetry": {...} | None, # from /live, or None if offline
"character_stats": {...} | None, # from /character-stats/<name>
"vitals": {...} | None, # last vitals from /live (subset)
"online": bool, # whether telemetry was found in /live
}
"""
name = character_name.strip()
live = await _get_json("/live")
players = live.get("players", []) if isinstance(live, dict) else []
telemetry = next(
(p for p in players if p.get("character_name") == name), None
)
char_stats: dict[str, Any] | None = None
try:
client = await _http()
resp = await client.get(f"/character-stats/{quote(name, safe='')}")
if resp.status_code == 200:
char_stats = resp.json()
except Exception:
char_stats = None
return {
"character_name": name,
"online": telemetry is not None,
"telemetry": telemetry,
"character_stats": char_stats,
}
async def get_inventory(character_name: str) -> dict[str, Any]:
"""Full inventory for one character. Items only — for filtered queries
use get_inventory_search."""
client = await _http()
resp = await client.get(f"/inventory/{quote(character_name, safe='')}")
resp.raise_for_status()
return resp.json()
async def get_inventory_search(
character_name: str, filters: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Filtered inventory search. `filters` is a dict of query params, e.g.
{"name": "pearl", "armor_level_min": 500}.
Caller is expected to know the supported filters from the dereth-tracker
/inventory/{name}/search route pass through opaquely.
"""
client = await _http()
resp = await client.get(
f"/inventory/{quote(character_name, safe='')}/search",
params=filters or {},
)
resp.raise_for_status()
return resp.json()
async def search_items_global(filters: dict[str, Any]) -> dict[str, Any]:
"""Cross-character item search via the inventory service's /search/items.
Use this INSTEAD of looping per-character when the user asks "find an X
on any of my chars" — one DB query vs. 60+ HTTP roundtrips.
Common filter keys (passed straight through as query params):
include_all_characters: bool (set true to search every char)
character: str (single char) | characters: "A,B,C"
text: str (name/description substring)
has_spell: "Legendary Acid Ward" exact spell name
spell_contains: "Legendary" substring match
legendary_cantrips: "Foo,Bar"
equipment_status: "equipped" | "unequipped"
equipment_slot: int (bitmask: 4=chest, 2048=bracelet, 4096=ring, ...)
slot_names: "Bracelet,Ring"
armor_only / jewelry_only / weapon_only: bool
min_armor / max_armor / min_damage / max_damage: int
...and many more see /search/items endpoint docs.
"""
client = await _http()
# Default to all-character search if caller didn't scope; otherwise the
# endpoint refuses with a 400.
params = dict(filters or {})
if not any(
k in params
for k in ("character", "characters", "include_all_characters")
):
params["include_all_characters"] = True
resp = await client.get("/search/items", params=params)
resp.raise_for_status()
return resp.json()
async def get_combat_stats(character_name: str) -> dict[str, Any]:
"""Lifetime + session combat stats for one character (per-element split,
monster encounters, surge counts)."""
client = await _http()
resp = await client.get(f"/combat-stats/{quote(character_name, safe='')}")
resp.raise_for_status()
return resp.json()
async def get_equipment_cantrips(character_name: str) -> dict[str, Any]:
"""Currently-equipped items + their active cantrip/spell state."""
client = await _http()
resp = await client.get(
f"/equipment-cantrip-state/{quote(character_name, safe='')}"
)
resp.raise_for_status()
return resp.json()
async def get_quest_status() -> dict[str, Any]:
"""All characters' active quest timers and progress."""
return await _get_json("/quest-status")
async def get_server_health() -> dict[str, Any]:
"""Coldeve server status: up/down, latency, current player count, uptime."""
return await _get_json("/server-health")
async def suitbuilder_search(
params: dict[str, Any], max_phase_events: int = 50
) -> dict[str, Any]:
"""Drive a suitbuilder constraint search synchronously.
The dereth-tracker /inv/suitbuilder/search endpoint is an SSE stream.
We collect events until the stream closes, drop intermediate phase
chatter (keeping the last N), and return:
{ "final_suits": [...], "phases": [...latest few...] }
`params` is the JSON body the suitbuilder expects. Call it like the
/suitbuilder.html page does.
"""
client = await _http()
final: list[dict[str, Any]] = []
phases: list[dict[str, Any]] = []
# Use a fresh long-timeout client for the SSE stream — don't tie up the
# shared pool for a 5-minute search.
async with httpx.AsyncClient(
base_url=TRACKER_URL, timeout=httpx.Timeout(300.0, connect=10.0)
) as stream_client:
async with stream_client.stream(
"POST",
"/inv/suitbuilder/search",
json=params,
headers={"Content-Type": "application/json"},
) as resp:
event_name = "message"
data_lines: list[str] = []
async for line_bytes in resp.aiter_lines():
line = line_bytes.rstrip("\r")
if line.startswith("event:"):
event_name = line[6:].strip()
elif line.startswith("data:"):
data_lines.append(line[5:].strip())
elif line == "":
# Dispatch
if data_lines:
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
payload = {"raw": "\n".join(data_lines)}
if event_name == "result" or event_name == "final":
final.append(payload)
elif event_name == "error":
phases.append({"event": "error", "data": payload})
else:
phases.append({"event": event_name, "data": payload})
phases = phases[-max_phase_events:]
data_lines = []
event_name = "message"
return {
"final_suits": final,
"phases": phases[-max_phase_events:],
"phase_count": len(phases),
}
# ─── Cleanup ────────────────────────────────────────────────────────
async def shutdown() -> None:
"""Close shared resources. Call from MCP server lifespan / on exit."""
global _http_client, _db_pool
if _http_client is not None:
await _http_client.aclose()
_http_client = None
if _db_pool is not None:
await _db_pool.close()
_db_pool = None

View file

@ -1,39 +0,0 @@
; Alembic configuration file for database migrations
[alembic]
; Path to migration scripts directory
script_location = alembic
; Default SQLAlchemy URL for migrations (use DATABASE_URL env var to override)
sqlalchemy.url = postgresql://postgres:password@localhost:5432/dereth
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
level = NOTSET
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View file

@ -1,64 +0,0 @@
"""Alembic environment configuration for database migrations.
Configures offline and online migration contexts using SQLAlchemy
and the target metadata defined in db_async.metadata.
"""
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Alembic Config object provides access to values in the .ini file
config = context.config
# Override sqlalchemy.url with DATABASE_URL environment variable if provided
database_url = os.getenv('DATABASE_URL', config.get_main_option('sqlalchemy.url'))
config.set_main_option('sqlalchemy.url', database_url)
# Set up Python logging according to config file
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from db_async import metadata # noqa
target_metadata = metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode using literal SQL script generation."""
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode against a live database connection."""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -1,25 +0,0 @@
<%#
Alembic migration script template generated by 'alembic revision'.
Edit the upgrade() and downgrade() functions to apply schema changes.
%>
"""
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '${up_revision}'
down_revision = ${repr(down_revision) if down_revision else None}
branch_labels = ${repr(branch_labels) if branch_labels else None}
depends_on = ${repr(depends_on) if depends_on else None}
def upgrade():
"""Upgrade migrations go here."""
pass
def downgrade():
"""Downgrade migrations go here."""
pass

View file

@ -1,5 +0,0 @@
"""
This directory will hold Alembic migration scripts.
Each migration filename should follow the naming convention:
<revision_id>_<slug>.py
"""

76
db.py
View file

@ -1,59 +1,15 @@
"""SQLite3 helper module for local telemetry storage.
Provides functions to initialize the local database schema and save
telemetry snapshots into history and live_state tables.
Enforces WAL mode, size limits, and auto-vacuum for efficient storage.
"""
import os
import sqlite3 import sqlite3
from typing import Dict from typing import Dict
from datetime import datetime, timedelta
# Local SQLite database file name (used when running without TimescaleDB)
DB_FILE = "dereth.db" DB_FILE = "dereth.db"
# Maximum allowed database size (in MB). Defaults to 2048 (2GB). Override via env DB_MAX_SIZE_MB.
MAX_DB_SIZE_MB = int(os.getenv("DB_MAX_SIZE_MB", "2048"))
# Retention window for telemetry history in days (currently not auto-enforced).
# Override via env DB_RETENTION_DAYS for future cleanup scripts.
MAX_RETENTION_DAYS = int(os.getenv("DB_RETENTION_DAYS", "7"))
# SQLite runtime limits customization
DB_MAX_SQL_LENGTH = int(os.getenv("DB_MAX_SQL_LENGTH", "1000000000"))
DB_MAX_SQL_VARIABLES = int(os.getenv("DB_MAX_SQL_VARIABLES", "32766"))
# Number of WAL frames to write before forcing a checkpoint (override via env DB_WAL_AUTOCHECKPOINT_PAGES)
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.getenv("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
def init_db() -> None: def init_db() -> None:
""" """Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
Initialize local SQLite database schema for telemetry logging. conn = sqlite3.connect(DB_FILE)
- Applies SQLite PRAGMA settings for performance and file size management
- Ensures WAL journaling and auto-vacuum for concurrency and compaction
- Creates telemetry_log for full history and live_state for latest snapshot per character
"""
# Open connection with a longer timeout
# Open connection with extended timeout for schema operations
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor() c = conn.cursor()
# Enable auto_vacuum FULL and rebuild DB so that deletions shrink the file
# Enable full auto-vacuum to shrink database file on deletes
c.execute("PRAGMA auto_vacuum=FULL;")
conn.commit()
# Rebuild database to apply auto_vacuum changes
c.execute("VACUUM;")
conn.commit()
# Switch to WAL mode for concurrency, adjust checkpointing, and enforce max size
# Configure write-ahead logging for concurrency and performance
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
# Auto-checkpoint after specified WAL frames to limit WAL file size
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# Create history log table for all telemetry snapshots # History log
c.execute( c.execute(
""" """
CREATE TABLE IF NOT EXISTS telemetry_log ( CREATE TABLE IF NOT EXISTS telemetry_log (
@ -76,7 +32,7 @@ def init_db() -> None:
""" """
) )
# Create live_state table for upserts of the most recent snapshot per character # Live snapshot (upsert)
c.execute( c.execute(
""" """
CREATE TABLE IF NOT EXISTS live_state ( CREATE TABLE IF NOT EXISTS live_state (
@ -103,27 +59,11 @@ def init_db() -> None:
def save_snapshot(data: Dict) -> None: def save_snapshot(data: Dict) -> None:
""" """Insert snapshot into history and upsert into live_state (with new fields)."""
Save a telemetry snapshot into the local SQLite database. conn = sqlite3.connect(DB_FILE)
Inserts a full record into telemetry_log (history) and upserts into live_state
for quick lookup of the most recent data per character.
Respects WAL mode and checkpoint settings on each connection.
"""
# Open new connection with extended timeout for inserting data
conn = sqlite3.connect(DB_FILE, timeout=30)
# Bump SQLite runtime limits on this connection
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, DB_MAX_SQL_LENGTH)
conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, DB_MAX_SQL_VARIABLES)
c = conn.cursor() c = conn.cursor()
# Ensure WAL mode and checkpointing settings on this connection
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute(f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}")
# Insert the snapshot into the telemetry_log (history) table # Insert full history row
c.execute( c.execute(
""" """
INSERT INTO telemetry_log ( INSERT INTO telemetry_log (
@ -151,7 +91,7 @@ def save_snapshot(data: Dict) -> None:
), ),
) )
# Upsert (insert or update) the latest snapshot into live_state table # Upsert into live_state
c.execute( c.execute(
""" """
INSERT INTO live_state ( INSERT INTO live_state (

View file

@ -1,418 +0,0 @@
"""Asynchronous database layer for telemetry service using PostgreSQL/TimescaleDB.
Defines table schemas via SQLAlchemy Core and provides an
initialization function to set up TimescaleDB hypertable.
"""
import os
import sqlalchemy
from datetime import datetime, timedelta, timezone
from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func
import bcrypt as _bcrypt
# Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth"
)
# Async database client with explicit connection pool configuration and query timeout
database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120)
# Metadata for SQLAlchemy Core
# SQLAlchemy metadata container for table definitions
metadata = MetaData()
# --- Table Definitions ---
# Table for storing raw telemetry snapshots at scale (converted to hypertable)
telemetry_events = Table(
# Time-series hypertable storing raw telemetry snapshots from plugins
"telemetry_events",
metadata,
Column("character_name", String, nullable=False, index=True),
Column("char_tag", String, nullable=True),
Column("session_id", String, nullable=False, index=True),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("ew", Float, nullable=False),
Column("ns", Float, nullable=False),
Column("z", Float, nullable=False),
Column("kills", Integer, nullable=False),
Column("kills_per_hour", Float, nullable=True),
Column("onlinetime", String, nullable=True),
Column("deaths", Integer, nullable=False),
Column("total_deaths", Integer, nullable=True),
Column("rares_found", Integer, nullable=False),
Column("prismatic_taper_count", Integer, nullable=False),
Column("vt_state", String, nullable=True),
# New telemetry metrics
Column("mem_mb", Float, nullable=True),
Column("cpu_pct", Float, nullable=True),
Column("mem_handles", Integer, nullable=True),
Column("latency_ms", Float, nullable=True),
# Server-side receive time. The `timestamp` column above is the CLIENT's
# self-reported wall clock and drifts up to ~90s across machines, so the
# "online" window must use this server-stamped value instead (see /live
# cache query). Nullable so pre-migration rows fall back to `timestamp`.
Column("received_at", DateTime(timezone=True), nullable=True),
)
# Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp
Index(
"ix_telemetry_events_char_ts",
telemetry_events.c.character_name,
telemetry_events.c.timestamp,
)
# Table for persistent total kills per character
char_stats = Table(
# Stores cumulative kills per character in a single-row upsert table
"char_stats",
metadata,
Column("character_name", String, primary_key=True),
Column("total_kills", Integer, nullable=False, default=0),
)
# Table for persistent total rare counts per character
rare_stats = Table(
# Stores cumulative rare event counts per character
"rare_stats",
metadata,
Column("character_name", String, primary_key=True),
Column("total_rares", Integer, nullable=False, default=0),
)
rare_stats_sessions = Table(
# Stores per-session rare counts; composite PK (character_name, session_id)
"rare_stats_sessions",
metadata,
Column("character_name", String, primary_key=True),
Column("session_id", String, primary_key=True),
Column("session_rares", Integer, nullable=False, default=0),
)
# Per-character persistent combat stats (lifetime accumulation, Mag-Tools style)
combat_stats = Table(
"combat_stats",
metadata,
Column("character_name", String, primary_key=True),
Column("timestamp", DateTime(timezone=True), nullable=False),
Column("stats_data", JSON, nullable=False),
)
# Per-session combat stats snapshots (session history)
combat_stats_sessions = Table(
"combat_stats_sessions",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("session_id", String, nullable=False, index=True),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("stats_data", JSON, nullable=False),
)
# Table for recording spawn events (mob creates) for heatmap analysis
spawn_events = Table(
# Records individual mob spawn occurrences for heatmap and analysis
"spawn_events",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False),
Column("mob", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("ew", Float, nullable=False),
Column("ns", Float, nullable=False),
Column("z", Float, nullable=False),
)
# Table for recording individual rare spawn events for analysis
rare_events = Table(
# Records individual rare mob events for detailed analysis and heatmaps
"rare_events",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False),
Column("name", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("ew", Float, nullable=False),
Column("ns", Float, nullable=False),
Column("z", Float, nullable=False),
)
character_inventories = Table(
# Stores complete character inventory snapshots with searchable fields
"character_inventories",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("item_id", BigInteger, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False),
# Extracted searchable fields
Column("name", String),
Column("icon", Integer),
Column("object_class", Integer, index=True),
Column("value", Integer, index=True),
Column("burden", Integer),
Column("has_id_data", Boolean),
# Complete item data as JSONB
Column("item_data", JSON, nullable=False),
# Unique constraint to prevent duplicate items per character
UniqueConstraint("character_name", "item_id", name="uq_char_item"),
)
# Portals table with coordinate-based uniqueness and 1-hour retention
portals = Table(
# Stores unique portals by coordinates with 1-hour retention
"portals",
metadata,
Column("id", Integer, primary_key=True),
Column("portal_name", String, nullable=False),
Column("ns", Float, nullable=False),
Column("ew", Float, nullable=False),
Column("z", Float, nullable=False),
Column("discovered_at", DateTime(timezone=True), nullable=False, index=True),
Column("discovered_by", String, nullable=False),
)
# Server health monitoring: only current state is kept.
# Historical health checks were removed — nothing read from them.
server_status = Table(
# Current server status and uptime tracking
"server_status",
metadata,
Column("server_name", String, primary_key=True),
Column("current_status", String(10), nullable=False),
Column("last_seen_up", DateTime(timezone=True), nullable=True),
Column("last_restart", DateTime(timezone=True), nullable=True),
Column("total_uptime_seconds", BigInteger, default=0),
Column("last_check", DateTime(timezone=True), nullable=True),
Column("last_latency_ms", Float, nullable=True),
Column("last_player_count", Integer, nullable=True),
)
character_stats = Table(
"character_stats",
metadata,
Column("character_name", String, primary_key=True, nullable=False),
Column(
"timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()
),
Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True),
Column("luminance_earned", BigInteger, nullable=True),
Column("luminance_total", BigInteger, nullable=True),
Column("deaths", Integer, nullable=True),
Column("stats_data", JSON, nullable=False),
)
# User accounts for app-level authentication
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("username", String, nullable=False, unique=True),
Column("password_hash", String, nullable=False),
Column("is_admin", Boolean, nullable=False, default=False),
Column(
"created_at", DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable.
Creates all defined tables and ensures the TimescaleDB extension is
installed. Converts telemetry_events table into a hypertable for efficient
time-series data storage.
"""
# Create tables in Postgres
engine = sqlalchemy.create_engine(DATABASE_URL)
# Reflects metadata definitions into actual database tables via SQLAlchemy
metadata.create_all(engine)
# Ensure TimescaleDB extension is installed and telemetry_events is a hypertable
# Run DDL in autocommit mode so errors don't abort subsequent statements
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Install extension if missing
try:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
except Exception as e:
print(f"Warning: failed to create extension timescaledb: {e}")
# Convert to hypertable, migrating existing data and skipping default index creation
try:
conn.execute(
text(
"SELECT create_hypertable('telemetry_events', 'timestamp', "
"if_not_exists => true, migrate_data => true, create_default_indexes => false)"
)
)
except Exception as e:
print(f"Warning: failed to create hypertable telemetry_events: {e}")
except Exception as e:
print(f"Warning: timescale extension/hypertable setup failed: {e}")
# Ensure composite index exists for efficient time-series queries by character
try:
with engine.connect() as conn:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"ON telemetry_events (character_name, timestamp)"
)
)
except Exception as e:
print(
f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}"
)
# Add the server-receive-time column to existing deployments (idempotent).
# Used as the clock-skew-proof basis for the "online" window in /live.
try:
with engine.connect() as conn:
conn.execute(
text(
"ALTER TABLE telemetry_events "
"ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ"
)
)
except Exception as e:
print(f"Warning: failed to add telemetry_events.received_at column: {e}")
# Add retention and compression policies on the hypertable
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Retain only recent data (default 7 days or override via DB_RETENTION_DAYS)
days = int(os.getenv("DB_RETENTION_DAYS", "7"))
conn.execute(
text(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
)
)
# Compress chunks older than 1 day
conn.execute(
text(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
)
)
except Exception as e:
print(f"Warning: failed to set retention/compression policies: {e}")
# Ensure spawn_events is a hypertable with a 7-day retention policy.
# This is idempotent — if already a hypertable, create_hypertable is a no-op
# when if_not_exists=TRUE. The existing 482M-row table needed a manual
# migration (see docs/plans/spawn_events_cleanup.md); this block keeps the
# policy alive on subsequent deploys.
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Try to convert spawn_events to a hypertable if it isn't already.
# migrate_data=FALSE is safe because the manual migration handled it;
# if someone creates a fresh DB, the table is empty and this converts it.
conn.execute(
text(
"SELECT create_hypertable('spawn_events', 'timestamp', "
"if_not_exists => TRUE, migrate_data => FALSE, "
"chunk_time_interval => INTERVAL '1 day')"
)
)
# 7-day retention
conn.execute(
text(
"SELECT add_retention_policy('spawn_events', INTERVAL '7 days', if_not_exists => TRUE)"
)
)
except Exception as e:
print(f"Warning: failed to set spawn_events hypertable/retention: {e}")
# Create unique constraint on rounded portal coordinates
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Drop old portal_discoveries table if it exists
conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE"))
# Create unique constraint on rounded coordinates for the new portals table
conn.execute(
text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))"""
)
)
# Create index on coordinates for efficient lookups
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
)
)
print("Portal table indexes and constraints created successfully")
except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}")
# Ensure character_stats table exists with JSONB column type
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(
text("""
CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level INTEGER,
total_xp BIGINT,
unassigned_xp BIGINT,
luminance_earned BIGINT,
luminance_total BIGINT,
deaths INTEGER,
stats_data JSONB NOT NULL
)
""")
)
print("character_stats table created/verified successfully")
except Exception as e:
print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals():
"""Clean up portals older than 1 hour."""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1)
# Delete old portals
result = await database.execute(
"DELETE FROM portals WHERE discovered_at < :cutoff_time",
{"cutoff_time": cutoff_time},
)
print(f"Cleaned up {result} portals older than 1 hour")
return result
except Exception as e:
print(f"Warning: failed to cleanup old portals: {e}")
return 0
async def seed_users():
"""Seed default users if the users table is empty."""
try:
count = await database.fetch_val("SELECT COUNT(*) FROM users")
if count > 0:
print(f"Users table already has {count} users, skipping seed")
return
default_users = [
{"username": "erik", "password": "erik123", "is_admin": True},
{"username": "alex", "password": "AlexGillar100Killar", "is_admin": False},
{
"username": "lundberg",
"password": "JohanGillar100Kvinnor",
"is_admin": False,
},
]
for u in default_users:
pw_hash = _bcrypt.hashpw(u["password"].encode(), _bcrypt.gensalt()).decode()
await database.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
{
"username": u["username"],
"password_hash": pw_hash,
"is_admin": u["is_admin"],
},
)
role = "admin" if u["is_admin"] else "user"
print(f"Seeded {role} user: {u['username']}")
except Exception as e:
print(f"Warning: failed to seed users: {e}")

View file

@ -1,16 +0,0 @@
#!/bin/bash
# Build frontend and deploy to static/ — run from MosswartOverlord root
set -e
echo "Building frontend..."
cd frontend && npm run build && cd ..
echo "Syncing build output to static/..."
rm -rf static/assets/
cp static/_build/index.html static/index.html
cp -r static/_build/assets/ static/assets/
cp static/_build/sw.js static/sw.js 2>/dev/null || true
rm -rf static/_build/
echo "Done! $(ls static/assets/ | wc -l) asset files deployed."
echo "Run 'git add static/ && git commit && git push' to deploy to server."

View file

@ -1,27 +0,0 @@
# Discord Rare Monitor Bot - Dockerfile
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY discord_rare_monitor.py .
COPY test_websocket.py .
COPY icon_mapping.py .
# Copy icons directory
COPY icons/ ./icons/
# Default environment variables
ENV DISCORD_RARE_BOT_TOKEN="" \
DERETH_TRACKER_WS_URL="ws://dereth-tracker:8765/ws/position" \
COMMON_RARE_CHANNEL_ID="1355328792184226014" \
GREAT_RARE_CHANNEL_ID="1353676584334131211" \
LOG_LEVEL="INFO"
# Run the bot
CMD ["python", "discord_rare_monitor.py"]

View file

@ -1,95 +0,0 @@
# Discord Rare Monitor Bot
A Discord bot that monitors the Dereth Tracker WebSocket stream for rare discoveries and posts filtered notifications to Discord channels.
## Features
- **Real-time Monitoring**: Connects to Dereth Tracker WebSocket for instant rare notifications
- **Smart Classification**: Automatically classifies rares as "common" or "great" based on keywords
- **Rich Embeds**: Posts formatted Discord embeds with location and timestamp information
- **Dual Channels**: Posts to separate channels for common and great rares
- **Robust Connection**: Automatic reconnection with exponential backoff on connection failures
## Rare Classification
### Common Rares
Items containing these keywords (except "Frore Crystal"):
- Crystal
- Jewel
- Pearl
- Elixir
- Kit
### Great Rares
All other rare discoveries not classified as common.
## Configuration
The bot is configured via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DISCORD_RARE_BOT_TOKEN` | Required | Discord bot token |
| `DERETH_TRACKER_WS_URL` | `ws://dereth-tracker:8765/ws/position` | WebSocket URL |
| `COMMON_RARE_CHANNEL_ID` | `1355328792184226014` | Discord channel for common rares |
| `GREAT_RARE_CHANNEL_ID` | `1353676584334131211` | Discord channel for great rares |
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
## Docker Usage
The bot is designed to run as a Docker container alongside the Dereth Tracker services:
```bash
# Build and start all services including the Discord bot
docker-compose up -d
# View bot logs
docker-compose logs discord-rare-monitor
# Restart just the bot
docker-compose restart discord-rare-monitor
```
## Manual Setup
1. Create a Discord application and bot at https://discord.com/developers/applications
2. Get the bot token and invite the bot to your Discord server
3. Set the `DISCORD_RARE_BOT_TOKEN` environment variable
4. Ensure the bot has permissions to send messages in the target channels
## Message Format
The bot listens for WebSocket messages with this structure:
```json
{
"type": "rare",
"character_name": "PlayerName",
"name": "Dark Heart",
"timestamp": "2025-06-22T16:00:00Z",
"ew": 12.34,
"ns": -56.78,
"z": 10.5
}
```
## Architecture
- **WebSocket Client**: Connects to Dereth Tracker's WebSocket stream
- **Message Filter**: Only processes `{"type": "rare"}` messages
- **Classifier**: Determines rare type based on name keywords
- **Discord Client**: Posts formatted embeds to appropriate channels
- **Retry Logic**: Automatic reconnection with exponential backoff
## Dependencies
- `discord.py>=2.3.0` - Discord API client
- `websockets>=11.0.0` - WebSocket client library
## Benefits
- **Zero Duplication**: Each rare generates exactly one notification
- **Real-time**: Instant notifications via WebSocket stream
- **Lightweight**: Minimal resource usage (~50MB RAM)
- **Reliable**: Robust error handling and reconnection logic
- **Integrated**: Seamlessly works with existing Dereth Tracker infrastructure

View file

@ -1,67 +0,0 @@
"""
Configuration module for Discord Rare Monitor Bot.
Centralizes environment variable handling and configuration constants.
"""
import os
from typing import Optional
class Config:
"""Configuration class for Discord Rare Monitor Bot."""
# Discord Configuration
DISCORD_TOKEN: str = os.getenv('DISCORD_RARE_BOT_TOKEN', '')
COMMON_RARE_CHANNEL_ID: int = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014'))
GREAT_RARE_CHANNEL_ID: int = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211'))
# WebSocket Configuration
WEBSOCKET_URL: str = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/position')
# Logging Configuration
LOG_LEVEL: str = os.getenv('LOG_LEVEL', 'INFO').upper()
# Rare Classification Configuration
COMMON_RARE_KEYWORDS: list = ["Crystal", "Jewel", "Pearl", "Elixir", "Kit"]
# WebSocket Retry Configuration
INITIAL_RETRY_DELAY: int = 5 # seconds
MAX_RETRY_DELAY: int = 300 # 5 minutes
@classmethod
def validate(cls) -> list:
"""Validate configuration and return list of errors."""
errors = []
if not cls.DISCORD_TOKEN:
errors.append("DISCORD_RARE_BOT_TOKEN environment variable is required")
if not cls.WEBSOCKET_URL:
errors.append("DERETH_TRACKER_WS_URL environment variable is required")
try:
cls.COMMON_RARE_CHANNEL_ID = int(cls.COMMON_RARE_CHANNEL_ID)
except (ValueError, TypeError):
errors.append("COMMON_RARE_CHANNEL_ID must be a valid integer")
try:
cls.GREAT_RARE_CHANNEL_ID = int(cls.GREAT_RARE_CHANNEL_ID)
except (ValueError, TypeError):
errors.append("GREAT_RARE_CHANNEL_ID must be a valid integer")
return errors
@classmethod
def log_config(cls, logger):
"""Log current configuration (excluding sensitive data)."""
logger.info("🔧 Discord Rare Monitor Configuration:")
logger.info(f" WebSocket URL: {cls.WEBSOCKET_URL}")
logger.info(f" Common Rare Channel ID: {cls.COMMON_RARE_CHANNEL_ID}")
logger.info(f" Great Rare Channel ID: {cls.GREAT_RARE_CHANNEL_ID}")
logger.info(f" Log Level: {cls.LOG_LEVEL}")
logger.info(f" Common Keywords: {cls.COMMON_RARE_KEYWORDS}")
logger.info(f" Discord Token: {'✅ Set' if cls.DISCORD_TOKEN else '❌ Not Set'}")
# Global config instance
config = Config()

File diff suppressed because it is too large Load diff

View file

@ -1,55 +0,0 @@
#!/usr/bin/env python3
"""
Generate mapping between icon filenames and rare item names.
"""
import os
import json
def generate_icon_mapping():
"""Generate mapping from icon filenames to display names."""
icons_dir = "/home/erik/MosswartOverlord/discord-rare-monitor/icons"
# Create reverse mapping from filename to display name
icon_mapping = {}
# List all PNG files in the icons directory
for filename in os.listdir(icons_dir):
if filename.endswith("_Icon.png"):
# Convert filename back to display name
# Remove _Icon.png suffix
base_name = filename[:-9]
# Convert underscores to spaces and handle apostrophes
display_name = base_name.replace("_", " ")
# Fix common patterns
display_name = display_name.replace("s Crystal", "'s Crystal")
display_name = display_name.replace("s Pearl", "'s Pearl")
display_name = display_name.replace("s Jewel", "'s Jewel")
display_name = display_name.replace("s Breath", "'s Breath")
display_name = display_name.replace("s Glaive", "'s Glaive")
display_name = display_name.replace("s Grip", "'s Grip")
display_name = display_name.replace("Tri Blade", "Tri-Blade")
display_name = display_name.replace("T ing", "T'ing")
# Special cases
if "Renari" in display_name:
display_name = display_name.replace("Renaris", "Renari's")
if "Leikotha" in display_name:
display_name = display_name.replace("Leikothas", "Leikotha's")
icon_mapping[filename] = display_name
# Save mapping to JSON file
with open(os.path.join(os.path.dirname(icons_dir), "icon_name_mapping.json"), "w") as f:
json.dump(icon_mapping, f, indent=2, sort_keys=True)
return icon_mapping
if __name__ == "__main__":
mapping = generate_icon_mapping()
print(f"Generated mapping for {len(mapping)} icons")
print("\nFirst 10 mappings:")
for i, (filename, display_name) in enumerate(list(mapping.items())[:10]):
print(f" {filename} -> {display_name}")

View file

@ -1,294 +0,0 @@
{
"Adepts_Fervor_Icon.png": "Adepts Fervor",
"Adherents_Crystal_Icon.png": "Adherent's Crystal",
"Alchemists_Crystal_Icon.png": "Alchemist's Crystal",
"Aquamarine_Foolproof_Icon.png": "Aquamarine Foolproof",
"Archers_Jewel_Icon.png": "Archer's Jewel",
"Aristocrats_Bracelet_Icon.png": "Aristocrats Bracelet",
"Artificers_Crystal_Icon.png": "Artificer's Crystal",
"Artists_Crystal_Icon.png": "Artist's Crystal",
"Assassins_Whisper_Icon.png": "Assassins Whisper",
"Astyrrians_Jewel_Icon.png": "Astyrrian's Jewel",
"Band_of_Elemental_Harmony_Icon.png": "Band of Elemental Harmony",
"Baton_of_Tirethas_Icon.png": "Baton of Tirethas",
"Bearded_Axe_of_Souia-Vey_Icon.png": "Bearded Axe of Souia-Vey",
"Ben_Tens_Crystal_Icon.png": "Ben Ten's Crystal",
"Berzerkers_Crystal_Icon.png": "Berzerker's Crystal",
"Black_Cloud_Bow_Icon.png": "Black Cloud Bow",
"Black_Garnet_Foolproof_Icon.png": "Black Garnet Foolproof",
"Black_Opal_Foolproof_Icon.png": "Black Opal Foolproof",
"Black_Thistle_Icon.png": "Black Thistle",
"Bloodmark_Crossbow_Icon.png": "Bloodmark Crossbow",
"Bracelet_of_Binding_Icon.png": "Bracelet of Binding",
"Bracers_of_Leikothas_Tears_Icon.png": "Bracers of Leikotha's Tears",
"Bradors_Frozen_Eye_Icon.png": "Bradors Frozen Eye",
"Brawlers_Crystal_Icon.png": "Brawler's Crystal",
"Breastplate_of_Leikothas_Tears_Icon.png": "Breastplate of Leikotha's Tears",
"Canfield_Cleaver_Icon.png": "Canfield Cleaver",
"Casino_Exquisite_Keyring_Icon.png": "Casino Exquisite Keyring",
"Champions_Demise_Icon.png": "Champions Demise",
"Chefs_Crystal_Icon.png": "Chef's Crystal",
"Chitin_Cracker_Icon.png": "Chitin Cracker",
"Circle_of_Pure_Thought_Icon.png": "Circle of Pure Thought",
"Converters_Crystal_Icon.png": "Converter's Crystal",
"Corruptors_Crystal_Icon.png": "Corruptor's Crystal",
"Corsairs_Arc_Icon.png": "Corsairs Arc",
"Count_Renaris_Equalizer_Icon.png": "Count Renari's Equalizer",
"Dart_Flicker_Icon.png": "Dart Flicker",
"Deaths_Grip_Staff_Icon.png": "Death's Grip Staff",
"Decapitators_Blade_Icon.png": "Decapitators Blade",
"Deceivers_Crystal_Icon.png": "Deceiver's Crystal",
"Defiler_of_Milantos_Icon.png": "Defiler of Milantos",
"Deru_Limb_Icon.png": "Deru Limb",
"Desert_Wyrm_Icon.png": "Desert Wyrm",
"Dodgers_Crystal_Icon.png": "Dodger's Crystal",
"Dragonspine_Bow_Icon.png": "Dragonspine Bow",
"Dread_Marauder_Shield_Icon.png": "Dread Marauder Shield",
"Dreamseer_Bangle_Icon.png": "Dreamseer Bangle",
"Drifters_Atlatl_Icon.png": "Drifters Atlatl",
"Dripping_Death_Icon.png": "Dripping Death",
"Duelists_Jewel_Icon.png": "Duelist's Jewel",
"Dusk_Coat_Icon.png": "Dusk Coat",
"Dusk_Leggings_Icon.png": "Dusk Leggings",
"Ebonwood_Shortbow_Icon.png": "Ebonwood Shortbow",
"Elysas_Crystal_Icon.png": "Elysa's Crystal",
"Emerald_Foolproof_Icon.png": "Emerald Foolproof",
"Enchanters_Crystal_Icon.png": "Enchanter's Crystal",
"Eternal_Health_Kit_Icon.png": "Eternal Health Kit",
"Eternal_Mana_Charge_Icon.png": "Eternal Mana Charge",
"Eternal_Mana_Kit_Icon.png": "Eternal Mana Kit",
"Eternal_Stamina_Kit_Icon.png": "Eternal Stamina Kit",
"Evaders_Crystal_Icon.png": "Evader's Crystal",
"Executors_Jewel_Icon.png": "Executor's Jewel",
"Eye_of_Muramm_Icon.png": "Eye of Muramm",
"Feathered_Razor_Icon.png": "Feathered Razor",
"Fire_Opal_Foolproof_Icon.png": "Fire Opal Foolproof",
"Fist_of_Three_Principles_Icon.png": "Fist of Three Principles",
"Fletchers_Crystal_Icon.png": "Fletcher's Crystal",
"Footmans_Boots_Icon.png": "Footmans Boots",
"Gauntlets_of_Leikothas_Tears_Icon.png": "Gauntlets of Leikotha's Tears",
"Gauntlets_of_the_Crimson_Star_Icon.png": "Gauntlets of the Crimson Star",
"Gelidite_Boots_Icon.png": "Gelidite Boots",
"Gelidite_Bracers_Icon.png": "Gelidite Bracers",
"Gelidite_Breastplate_Icon.png": "Gelidite Breastplate",
"Gelidite_Gauntlets_Icon.png": "Gelidite Gauntlets",
"Gelidite_Girth_Icon.png": "Gelidite Girth",
"Gelidite_Greaves_Icon.png": "Gelidite Greaves",
"Gelidite_Mitre_Icon.png": "Gelidite Mitre",
"Gelidite_Pauldrons_Icon.png": "Gelidite Pauldrons",
"Gelidite_Tassets_Icon.png": "Gelidite Tassets",
"Gelids_Jewel_Icon.png": "Gelid's Jewel",
"Girth_of_Leikothas_Tears_Icon.png": "Girth of Leikotha's Tears",
"Golden_Snake_Choker_Icon.png": "Golden Snake Choker",
"Greaves_of_Leikothas_Tears_Icon.png": "Greaves of Leikotha's Tears",
"Guardian_of_Pwyll_Icon.png": "Guardian of Pwyll",
"Heart_of_Darkest_Flame_Icon.png": "Heart of Darkest Flame",
"Helm_of_Leikothas_Tears_Icon.png": "Helm of Leikotha's Tears",
"Hevelios_Half-Moon_Icon.png": "Hevelios Half-Moon",
"Hieroglyph_of_Alchemy_Mastery_Icon.png": "Hieroglyph of Alchemy Mastery",
"Hieroglyph_of_Arcane_Enlightenment_Icon.png": "Hieroglyph of Arcane Enlightenment",
"Hieroglyph_of_Armor_Tinkering_Expertise_Icon.png": "Hieroglyph of Armor Tinkering Expertise",
"Hieroglyph_of_Cooking_Mastery_Icon.png": "Hieroglyph of Cooking Mastery",
"Hieroglyph_of_Creature_Enchantment_Mastery_Icon.png": "Hieroglyph of Creature Enchantment Mastery",
"Hieroglyph_of_Deception_Mastery_Icon.png": "Hieroglyph of Deception Mastery",
"Hieroglyph_of_Dirty_Fighting_Mastery_Icon.png": "Hieroglyph of Dirty Fighting Mastery",
"Hieroglyph_of_Dual_Wield_Mastery_Icon.png": "Hieroglyph of Dual Wield Mastery",
"Hieroglyph_of_Fealty_Icon.png": "Hieroglyph of Fealty",
"Hieroglyph_of_Finesse_Weapon_Mastery_Icon.png": "Hieroglyph of Finesse Weapon Mastery",
"Hieroglyph_of_Fletching_Mastery_Icon.png": "Hieroglyph of Fletching Mastery",
"Hieroglyph_of_Healing_Mastery_Icon.png": "Hieroglyph of Healing Mastery",
"Hieroglyph_of_Heavy_Weapon_Mastery_Icon.png": "Hieroglyph of Heavy Weapon Mastery",
"Hieroglyph_of_Impregnability_Icon.png": "Hieroglyph of Impregnability",
"Hieroglyph_of_Invulnerability_Icon.png": "Hieroglyph of Invulnerability",
"Hieroglyph_of_Item_Enchantment_Mastery_Icon.png": "Hieroglyph of Item Enchantment Mastery",
"Hieroglyph_of_Item_Tinkering_Expertise_Icon.png": "Hieroglyph of Item Tinkering Expertise",
"Hieroglyph_of_Jumping_Mastery_Icon.png": "Hieroglyph of Jumping Mastery",
"Hieroglyph_of_Leadership_Mastery_Icon.png": "Hieroglyph of Leadership Mastery",
"Hieroglyph_of_Life_Magic_Mastery_Icon.png": "Hieroglyph of Life Magic Mastery",
"Hieroglyph_of_Light_Weapon_Mastery_Icon.png": "Hieroglyph of Light Weapon Mastery",
"Hieroglyph_of_Lockpick_Mastery_Icon.png": "Hieroglyph of Lockpick Mastery",
"Hieroglyph_of_Magic_Item_Tinkering_Expertise_Icon.png": "Hieroglyph of Magic Item Tinkering Expertise",
"Hieroglyph_of_Magic_Resistance_Icon.png": "Hieroglyph of Magic Resistance",
"Hieroglyph_of_Mana_Conversion_Mastery_Icon.png": "Hieroglyph of Mana Conversion Mastery",
"Hieroglyph_of_Missile_Weapon_Mastery_Icon.png": "Hieroglyph of Missile Weapon Mastery",
"Hieroglyph_of_Monster_Attunement_Icon.png": "Hieroglyph of Monster Attunement",
"Hieroglyph_of_Person_Attunement_Icon.png": "Hieroglyph of Person Attunement",
"Hieroglyph_of_Recklessness_Mastery_Icon.png": "Hieroglyph of Recklessness Mastery",
"Hieroglyph_of_Shield_Mastery_Icon.png": "Hieroglyph of Shield Mastery",
"Hieroglyph_of_Sneak_Attack_Mastery_Icon.png": "Hieroglyph of Sneak Attack Mastery",
"Hieroglyph_of_Sprint_Icon.png": "Hieroglyph of Sprint",
"Hieroglyph_of_Two_Handed_Weapons_Mastery_Icon.png": "Hieroglyph of Two Handed Weapons Mastery",
"Hieroglyph_of_Void_Magic_Mastery_Icon.png": "Hieroglyph of Void Magic Mastery",
"Hieroglyph_of_War_Magic_Mastery_Icon.png": "Hieroglyph of War Magic Mastery",
"Hieroglyph_of_Weapon_Tinkering_Expertise_Icon.png": "Hieroglyph of Weapon Tinkering Expertise",
"Hieromancers_Crystal_Icon.png": "Hieromancer's Crystal",
"Hooded_Serpent_Slinger_Icon.png": "Hooded Serpent Slinger",
"Hunters_Crystal_Icon.png": "Hunter's Crystal",
"Huntsmans_Dart-Thrower_Icon.png": "Huntsmans Dart-Thrower",
"Ibriyas_Choice_Icon.png": "Ibriyas Choice",
"Ideograph_of_Acid_Protection_Icon.png": "Ideograph of Acid Protection",
"Ideograph_of_Armor_Icon.png": "Ideograph of Armor",
"Ideograph_of_Blade_Protection_Icon.png": "Ideograph of Blade Protection",
"Ideograph_of_Bludgeoning_Protection_Icon.png": "Ideograph of Bludgeoning Protection",
"Ideograph_of_Fire_Protection_Icon.png": "Ideograph of Fire Protection",
"Ideograph_of_Frost_Protection_Icon.png": "Ideograph of Frost Protection",
"Ideograph_of_Lightning_Protection_Icon.png": "Ideograph of Lightning Protection",
"Ideograph_of_Mana_Renewal_Icon.png": "Ideograph of Mana Renewal",
"Ideograph_of_Piercing_Protection_Icon.png": "Ideograph of Piercing Protection",
"Ideograph_of_Regeneration_Icon.png": "Ideograph of Regeneration",
"Ideograph_of_Revitalization_Icon.png": "Ideograph of Revitalization",
"Imbuers_Crystal_Icon.png": "Imbuer's Crystal",
"Imperial_Chevairds_Helm_Icon.png": "Imperial Chevairds Helm",
"Imperial_Topaz_Foolproof_Icon.png": "Imperial Topaz Foolproof",
"Infernos_Jewel_Icon.png": "Inferno's Jewel",
"Infinite_Deadly_Acid_Arrowheads_Icon.png": "Infinite Deadly Acid Arrowheads",
"Infinite_Deadly_Armor_Piercing_Arrowheads_Icon.png": "Infinite Deadly Armor Piercing Arrowheads",
"Infinite_Deadly_Blunt_Arrowheads_Icon.png": "Infinite Deadly Blunt Arrowheads",
"Infinite_Deadly_Broad_Arrowheads_Icon.png": "Infinite Deadly Broad Arrowheads",
"Infinite_Deadly_Electric_Arrowheads_Icon.png": "Infinite Deadly Electric Arrowheads",
"Infinite_Deadly_Fire_Arrowheads_Icon.png": "Infinite Deadly Fire Arrowheads",
"Infinite_Deadly_Frog_Crotch_Arrowheads_Icon.png": "Infinite Deadly Frog Crotch Arrowheads",
"Infinite_Deadly_Frost_Arrowheads_Icon.png": "Infinite Deadly Frost Arrowheads",
"Infinite_Elaborate_Dried_Rations_Icon.png": "Infinite Elaborate Dried Rations",
"Infinite_Ivory_Icon.png": "Infinite Ivory",
"Infinite_Leather_Icon.png": "Infinite Leather",
"Infinite_Simple_Dried_Rations_Icon.png": "Infinite Simple Dried Rations",
"Invigorating_Elixir_Icon.png": "Invigorating Elixir",
"Iron_Bull_Icon.png": "Iron Bull",
"Itakas_Naginata_Icon.png": "Itakas Naginata",
"Jet_Foolproof_Icon.png": "Jet Foolproof",
"Lichs_Pearl_Icon.png": "Lich's Pearl",
"Life_Givers_Crystal_Icon.png": "Life Giver's Crystal",
"Limitless_Lockpick_Icon.png": "Limitless Lockpick",
"Loop_of_Opposing_Benedictions_Icon.png": "Loop of Opposing Benedictions",
"Loves_Favor_Icon.png": "Loves Favor",
"Lugians_Pearl_Icon.png": "Lugian's Pearl",
"Mages_Jewel_Icon.png": "Mage's Jewel",
"Maguss_Pearl_Icon.png": "Magus's Pearl",
"Malachite_Slasher_Icon.png": "Malachite Slasher",
"Medicated_Health_Kit_Icon.png": "Medicated Health Kit",
"Medicated_Mana_Kit_Icon.png": "Medicated Mana Kit",
"Medicated_Stamina_Kit_Icon.png": "Medicated Stamina Kit",
"Melees_Jewel_Icon.png": "Melee's Jewel",
"Miraculous_Elixir_Icon.png": "Miraculous Elixir",
"Mirrored_Justice_Icon.png": "Mirrored Justice",
"Monarchs_Crystal_Icon.png": "Monarch's Crystal",
"Moriharus_Kitchen_Knife_Icon.png": "Moriharus Kitchen Knife",
"Morrigans_Vanity_Icon.png": "Morrigans Vanity",
"Necklace_of_Iniquity_Icon.png": "Necklace of Iniquity",
"Observers_Crystal_Icon.png": "Observer's Crystal",
"Olthois_Jewel_Icon.png": "Olthoi's Jewel",
"Orb_of_the_Ironsea_Icon.png": "Orb of the Ironsea",
"Oswalds_Crystal_Icon.png": "Oswald's Crystal",
"Patriarchs_Twilight_Coat_Icon.png": "Patriarchs Twilight Coat",
"Patriarchs_Twilight_Tights_Icon.png": "Patriarchs Twilight Tights",
"Pauldrons_of_Leikothas_Tears_Icon.png": "Pauldrons of Leikotha's Tears",
"Pearl_of_Acid_Baning_Icon.png": "Pearl of Acid Baning",
"Pearl_of_Blade_Baning_Icon.png": "Pearl of Blade Baning",
"Pearl_of_Blood_Drinking_Icon.png": "Pearl of Blood Drinking",
"Pearl_of_Bludgeon_Baning_Icon.png": "Pearl of Bludgeon Baning",
"Pearl_of_Defending_Icon.png": "Pearl of Defending",
"Pearl_of_Flame_Baning_Icon.png": "Pearl of Flame Baning",
"Pearl_of_Frost_Baning_Icon.png": "Pearl of Frost Baning",
"Pearl_of_Heart_Seeking_Icon.png": "Pearl of Heart Seeking",
"Pearl_of_Hermetic_Linking_Icon.png": "Pearl of Hermetic Linking",
"Pearl_of_Impenetrability_Icon.png": "Pearl of Impenetrability",
"Pearl_of_Lightning_Baning_Icon.png": "Pearl of Lightning Baning",
"Pearl_of_Pierce_Baning_Icon.png": "Pearl of Pierce Baning",
"Pearl_of_Spirit_Drinking_Icon.png": "Pearl of Spirit Drinking",
"Pearl_of_Swift_Killing_Icon.png": "Pearl of Swift Killing",
"Perennial_Argenory_Dye_Icon.png": "Perennial Argenory Dye",
"Perennial_Berimphur_Dye_Icon.png": "Perennial Berimphur Dye",
"Perennial_Botched_Dye_Icon.png": "Perennial Botched Dye",
"Perennial_Colban_Dye_Icon.png": "Perennial Colban Dye",
"Perennial_Hennacin_Dye_Icon.png": "Perennial Hennacin Dye",
"Perennial_Lapyan_Dye_Icon.png": "Perennial Lapyan Dye",
"Perennial_Minalim_Dye_Icon.png": "Perennial Minalim Dye",
"Perennial_Relanim_Dye_Icon.png": "Perennial Relanim Dye",
"Perennial_Thananim_Dye_Icon.png": "Perennial Thananim Dye",
"Perennial_Verdalim_Dye_Icon.png": "Perennial Verdalim Dye",
"Peridot_Foolproof_Icon.png": "Peridot Foolproof",
"Physicians_Crystal_Icon.png": "Physician's Crystal",
"Pictograph_of_Coordination_Icon.png": "Pictograph of Coordination",
"Pictograph_of_Endurance_Icon.png": "Pictograph of Endurance",
"Pictograph_of_Focus_Icon.png": "Pictograph of Focus",
"Pictograph_of_Quickness_Icon.png": "Pictograph of Quickness",
"Pictograph_of_Strength_Icon.png": "Pictograph of Strength",
"Pictograph_of_Willpower_Icon.png": "Pictograph of Willpower",
"Pillar_of_Fearlessness_Icon.png": "Pillar of Fearlessness",
"Pitfighters_Edge_Icon.png": "Pitfighters Edge",
"Red_Garnet_Foolproof_Icon.png": "Red Garnet Foolproof",
"Refreshing_Elixir_Icon.png": "Refreshing Elixir",
"Resisters_Crystal_Icon.png": "Resister's Crystal",
"Revenants_Scythe_Icon.png": "Revenants Scythe",
"Ridgeback_Dagger_Icon.png": "Ridgeback Dagger",
"Ring_of_Channeling_Icon.png": "Ring of Channeling",
"Rogues_Crystal_Icon.png": "Rogue's Crystal",
"Royal_Ladle_Icon.png": "Royal Ladle",
"Rune_of_Acid_Bane_Icon.png": "Rune of Acid Bane",
"Rune_of_Blade_Bane_Icon.png": "Rune of Blade Bane",
"Rune_of_Blood_Drinker_Icon.png": "Rune of Blood Drinker",
"Rune_of_Bludgeon_Bane_Icon.png": "Rune of Bludgeon Bane",
"Rune_of_Defender_Icon.png": "Rune of Defender",
"Rune_of_Dispel_Icon.png": "Rune of Dispel",
"Rune_of_Flame_Bane_Icon.png": "Rune of Flame Bane",
"Rune_of_Frost_Bane_Icon.png": "Rune of Frost Bane",
"Rune_of_Heart_Seeker_Icon.png": "Rune of Heart Seeker",
"Rune_of_Hermetic_Link_Icon.png": "Rune of Hermetic Link",
"Rune_of_Impenetrability_Icon.png": "Rune of Impenetrability",
"Rune_of_Lifestone_Recall_Icon.png": "Rune of Lifestone Recall",
"Rune_of_Lightning_Bane_Icon.png": "Rune of Lightning Bane",
"Rune_of_Pierce_Bane_Icon.png": "Rune of Pierce Bane",
"Rune_of_Portal_Recall_Icon.png": "Rune of Portal Recall",
"Rune_of_Spirit_Drinker_Icon.png": "Rune of Spirit Drinker",
"Rune_of_Swift_Killer_Icon.png": "Rune of Swift Killer",
"Scholars_Crystal_Icon.png": "Scholar's Crystal",
"Serpents_Flight_Icon.png": "Serpents Flight",
"Shield_of_Engorgement_Icon.png": "Shield of Engorgement",
"Shimmering_Skeleton_Key_Icon.png": "Shimmering Skeleton Key",
"Skullpuncher_Icon.png": "Skullpuncher",
"Smite_Icon.png": "Smite",
"Smithys_Crystal_Icon.png": "Smithy's Crystal",
"Spear_of_Lost_Truths_Icon.png": "Spear of Lost Truths",
"Spirit_Shifting_Staff_Icon.png": "Spirit Shifting Staff",
"Sprinters_Pearl_Icon.png": "Sprinter's Pearl",
"Squires_Glaive_Icon.png": "Squire's Glaive",
"Staff_of_All_Aspects_Icon.png": "Staff of All Aspects",
"Staff_of_Fettered_Souls_Icon.png": "Staff of Fettered Souls",
"Staff_of_Tendrils_Icon.png": "Staff of Tendrils",
"Star_of_Gharun_Icon.png": "Star of Gharun",
"Star_of_Tukal_Icon.png": "Star of Tukal",
"Steel_Butterfly_Icon.png": "Steel Butterfly",
"Steel_Wall_Boots_Icon.png": "Steel Wall Boots",
"Subjugator_Icon.png": "Subjugator",
"Sunstone_Foolproof_Icon.png": "Sunstone Foolproof",
"Swift_Strike_Ring_Icon.png": "Swift Strike Ring",
"Tassets_of_Leikothas_Tears_Icon.png": "Tassets of Leikotha's Tears",
"Thiefs_Crystal_Icon.png": "Thief's Crystal",
"Thorstens_Crystal_Icon.png": "Thorsten's Crystal",
"Thunderhead_Icon.png": "Thunderhead",
"Tings_Crystal_Icon.png": "Ting's Crystal",
"Tinkers_Crystal_Icon.png": "Tinker's Crystal",
"Tracker_Boots_Icon.png": "Tracker Boots",
"Tri_Blade_Spear_Icon.png": "Tri-Blade Spear",
"Tusked_Axe_of_Ayan_Baqur_Icon.png": "Tusked Axe of Ayan Baqur",
"Tuskers_Jewel_Icon.png": "Tusker's Jewel",
"Twin_Ward_Icon.png": "Twin Ward",
"Unchained_Prowess_Ring_Icon.png": "Unchained Prowess Ring",
"Ursuins_Pearl_Icon.png": "Ursuin's Pearl",
"Valkeers_Helm_Icon.png": "Valkeers Helm",
"Vaulters_Crystal_Icon.png": "Vaulter's Crystal",
"Wand_of_the_Frore_Crystal_Icon.png": "Wand of the Frore Crystal",
"Warriors_Crystal_Icon.png": "Warrior's Crystal",
"Warriors_Jewel_Icon.png": "Warrior's Jewel",
"Wayfarers_Pearl_Icon.png": "Wayfarer's Pearl",
"Weeping_Ring_Icon.png": "Weeping Ring",
"White_Sapphire_Foolproof_Icon.png": "White Sapphire Foolproof",
"Wings_of_Rakhil_Icon.png": "Wings of Rakhil",
"Winters_Heart_Icon.png": "Winters Heart",
"Yellow_Topaz_Foolproof_Icon.png": "Yellow Topaz Foolproof",
"Zefirs_Breath_Icon.png": "Zefir's Breath",
"Zefirs_Crystal_Icon.png": "Zefir's Crystal",
"Zharalim_Crookblade_Icon.png": "Zharalim Crookblade",
"Zircon_Foolproof_Icon.png": "Zircon Foolproof"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Some files were not shown because too many files have changed in this diff Show more