Compare commits

..

1 commit

Author SHA1 Message Date
erik
dc774beb6b WIP: snapshot of all local changes 2025-05-03 07:56:43 +00:00
16950 changed files with 1330 additions and 146524 deletions

11
.gitignore vendored
View file

@ -1,11 +0,0 @@
.venv
__pycache__
static/v2/
frontend/node_modules/
# 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/

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`
- History endpoint: `GET /history`
- Plugin WS endpoint: `/ws/position` (authenticated)
- Browser WS endpoint: `/ws/live` (unauthenticated)
- 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.

226
CLAUDE.md
View file

@ -1,226 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Dereth Tracker is a real-time telemetry service for game world tracking. It's a FastAPI-based WebSocket and HTTP API service that ingests player position/stats data via plugins and provides live map visualization through a web interface.
## Key Components
### Main Service (main.py)
- WebSocket endpoint `/ws/position` receives telemetry and inventory events
- Routes inventory events to inventory service via HTTP
- Handles real-time player tracking and map updates
### Inventory Service (inventory-service/main.py)
- Separate FastAPI service for inventory management
- Processes inventory JSON into normalized PostgreSQL tables
- Provides search API with advanced filtering and sorting
- Uses comprehensive enum database for translating game IDs to readable names
### Database Architecture
- **Telemetry DB**: TimescaleDB for time-series player tracking data
- **Inventory DB**: PostgreSQL with normalized schema for equipment data
- `items`: Core item properties
- `item_combat_stats`: Armor level, damage bonuses
- `item_enhancements`: Material, item sets, tinkering
- `item_spells`: Spell names and categories
- `item_raw_data`: Original JSON for complex queries
## Memories and Known Bugs
* Fixed: Material names now properly display (e.g., "Gold Celdon Girth" instead of "Unknown_Material_Gold Celdon Girth")
* Fixed: Slot column shows "-" instead of "Unknown" for items without slot data
* Fixed: All 208 items in Larsson's inventory now process successfully (was 186 with 22 SQL type errors)
* Added: Type column in inventory search using object_classes enum for accurate item type classification
* Note: ItemType data is inconsistent in JSON - using ObjectClass as primary source for Type column
## Recent Fixes (September 2025)
### Portal Coordinate Rounding Fix ✅ RESOLVED
* **Problem**: Portal insertion failed with duplicate key errors due to coordinate rounding mismatch
* **Root Cause**: Code used 2 decimal places (`ROUND(ns::numeric, 2)`) but database constraint used 1 decimal place
* **Solution**: Changed all portal coordinate checks to use 1 decimal place to match DB constraint
* **Result**: 98% reduction in duplicate key errors (from 600+/min to ~11/min)
* **Location**: `main.py` lines ~1989, 1996, 2025, 2047
### Character Display Issues ✅ RESOLVED
* **Problem**: Some characters (e.g., "Crazed n Dazed") not appearing in frontend
* **Root Cause**: Database connection pool exhaustion from portal error spam
* **Solution**: Fixed portal errors to reduce database load
* **Result**: Characters now display correctly after portal fix
### Docker Container Deployment
* **Issue**: Code changes require container rebuild with `--no-cache` flag
* **Command**: `docker compose build --no-cache dereth-tracker`
* **Reason**: Docker layer caching can prevent updated source code from being copied
## Current Known Issues
### Minor Portal Race Conditions
* **Status**: ~11 duplicate key errors per minute (down from 600+)
* **Cause**: Multiple players discovering same portal simultaneously
* **Impact**: Minimal - errors are caught and handled gracefully
* **Handling**: Try/catch in code logs as debug messages and updates portal timestamp
* **Potential Fix**: PostgreSQL ON CONFLICT DO UPDATE (upsert pattern) would eliminate completely
### Database Initialization Warnings
* **TimescaleDB Hypertable**: `telemetry_events` fails to become hypertable due to primary key constraint
* **Impact**: None - table works as regular PostgreSQL table
* **Warning**: "cannot create a unique index without the column 'timestamp'"
### Connection Pool Under Load
* **Issue**: Database queries can timeout when connection pool is exhausted
* **Symptom**: Characters may not appear during high error load
* **Mitigation**: Portal error fix significantly reduced this issue
## Equipment Suit Builder
### Status: PRODUCTION READY
Real-time equipment optimization engine for building optimal character loadouts by searching across multiple characters' inventories (mules). Uses Mag-SuitBuilder constraint satisfaction algorithms.
**Core Features:**
- Multi-character inventory search across 100+ characters, 25,000+ items
- Armor set constraints (primary 5-piece + secondary 4-piece set support)
- Cantrip/ward spell optimization with bitmap-based overlap detection
- Crit damage rating optimization
- Locked slots with set/spell preservation across searches
- Real-time SSE streaming with progressive phase updates
- Suit summary with copy-to-clipboard functionality
- Stable deterministic sorting for reproducible results
**Access:** `/suitbuilder.html`
**Architecture Details:** See `docs/plans/2026-02-09-suitbuilder-architecture.md`
### Known Limitations
- Slot-aware spell filtering not yet implemented (e.g., underclothes have limited spell pools but system treats all slots equally)
- All spells weighted equally (no priority/importance weighting yet)
- See architecture doc for future enhancement roadmap
## Technical Notes for Development
### Database Performance
- Connection pool: 5-20 connections (configured in `db_async.py`)
- Under heavy error load, pool exhaustion can cause 2-minute query timeouts
- Portal error fix significantly improved database performance
### Docker Development Workflow
1. **Code Changes**: Edit source files locally
2. **Rebuild**: `docker compose build --no-cache dereth-tracker` (required for code changes)
3. **Deploy**: `docker compose up -d dereth-tracker`
4. **Debug**: `docker logs mosswartoverlord-dereth-tracker-1` and `docker logs dereth-db`
### Frontend Architecture
- **Main Map**: `static/index.html` - Real-time player tracking
- **Inventory Search**: `static/inventory.html` - Advanced item filtering
- **Suitbuilder**: `static/suitbuilder.html` - Equipment optimization interface
- **All static files**: Served directly by FastAPI StaticFiles
### DOM Optimization Status ✅ COMPLETE (September 2025)
* **Achievement**: 100% DOM element reuse with zero element creation after initial render
* **Performance**: ~5ms render time for 69 players, eliminated 4,140+ elements/minute creation
* **Implementation**: Element pooling system with player name mapping for O(1) lookup
* **Monitoring**: Color-coded console output (✨ green = optimized, ⚡ yellow = partial, 🔥 red = poor)
* **Status**: Production ready - achieving perfect element reuse consistently
**Current Render Stats**:
- ✅ This render: 0 dots created, 69 reused | 0 list items created, 69 reused
- ✅ Lifetime: 69 dots created, 800+ reused | 69 list items created, 800+ reused
**Remaining TODO**:
- ❌ Fix CSS Grid layout for player sidebar (deferred per user request)
- ❌ Extend optimization to trails and portal rendering
- ❌ Add memory usage tracking
### WebSocket Endpoints
- `/ws/position`: Plugin telemetry, inventory, portal, rare events (authenticated)
- `/ws/live`: Browser client commands and live updates (unauthenticated)
---
## 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.

File diff suppressed because it is too large Load diff

View file

@ -1,48 +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
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 \
SHARED_SECRET=your_shared_secret
## 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

463
README.md
View file

@ -1,424 +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.
FastAPI backend + React frontend + PostgreSQL (TimescaleDB) + Discord integrations,
all driven by WebSocket events from the companion [MosswartMassacre](https://github.com/SawatoMosswartsEnjoyersClub/MosswartMassacre) DECAL plugin.
---
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
- [Architecture](#architecture)
- [Features](#features) - [Features](#features)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Deploying Changes](#deploying-changes) - [Usage](#usage)
- [WebSocket Contract](#websocket-contract) - [API Reference](#api-reference)
- [HTTP API Reference](#http-api-reference)
- [Frontend](#frontend) - [Frontend](#frontend)
- [AI Assistant (Overlord Agent)](#ai-assistant-overlord-agent)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Operations & Health](#operations--health) - [Sample Payload](#sample-payload)
- [Contributing](#contributing) - [Contributing](#contributing)
---
## Overview ## Overview
Mosswart Overlord is the backend that consumes a firehose of telemetry, vitals, inventory, combat, and chat events from 60+ characters running the `MosswartMassacre` plugin. It stores selected data in TimescaleDB, runs analytics (combat stats, idle/death detection), and broadcasts live updates to connected browser clients. This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data.
The frontend is a React + Vite app served at `/` with a live map, draggable windows (inventory, chat, combat, radar, etc.), and a server uptime sidebar. The previous vanilla JS frontend is preserved at `/classic`. - SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript.
## Architecture - An analytics dashboard for visualizing kills and session metrics.
```
┌─────────────────────────┐
│ MosswartMassacre (C#) │ ← plugin per game client
└────────────┬────────────┘
│ WebSocket /ws/position (authenticated)
┌────────────────────────────────────────────────────────┐
│ dereth-tracker (FastAPI, Docker) │
│ • main.py — WS routing, analytics, broadcasts │
│ • idle/death detection → Discord webhook │
│ • combat stats delta/lifetime accumulation │
│ • vital sharing relay (cross-machine) │
└──┬──────────────────┬────────────────────┬────────────┘
│ │ │
│ WS /ws/live │ HTTP │ HTTP
▼ ▼ ▼
┌──────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browsers │ │ inventory-svc │ │ Discord bot │
│ (React) │ │ (FastAPI, Docker)│ │ (rare monitor) │
└────┬─────┘ └────────┬─────────┘ └──────────────────┘
│ ▼
│ ┌──────────────┐
│ │ inventory-db │
│ └──────────────┘
│ /api/agent/* (host-side, OUTSIDE Docker)
┌────────────────────────────────────────┐
│ overlord-agent (FastAPI, systemd) │ ← runs as dedicated unprivileged user
│ • shells out to `claude -p ...` │ /var/lib/overlord-agent home,
│ • MCP server: live-state Q&A tools │ strict settings, no /home/erik
└────────────────────────────────────────┘
┌──────────────┐
│ dereth-db │ ← TimescaleDB (telemetry, spawns, rares, portals)
└──────────────┘
```
Most services run via Docker Compose. **`overlord-agent` is host-side**
(systemd) because it shells out to the `claude` CLI which depends on
host-side credentials — see [AI Assistant](#ai-assistant-overlord-agent).
## Features ## Features
### Live Data - **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
- **Live Map** — real-time player positions, dots, trails, portals, heatmap - **GET /live**: Fetch active players seen in the last 30 seconds.
- **WebSocket firehose** (`/ws/live`) — broadcasts every incoming event to browsers - **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **Per-client subscriptions** — clients can send `{"type":"subscribe","message_types":[...]}` to receive only specific event types (the Discord rare monitor bot uses this to filter the 82GB/day firehose down to just `rare` and `chat`) - **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
### Inventory - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
- Full inventory snapshot on login + incremental `inventory_delta` updates (add/update/remove)
- Per-character live refresh in the browser (debounced 2s)
- Advanced search with filters: material, set, armor level, spells, tinks, workmanship, etc.
- **Suitbuilder** at `/suitbuilder.html` — constraint-based armor optimization across multiple mule inventories with primary/secondary set support, cantrip overlap detection, and real-time SSE streaming
### Combat Stats (Mag-Tools style)
- Plugin parses combat chat into session deltas
- Backend accumulates lifetime totals from per-session snapshots
- Offense/defense broken out per damage element
- Browser combat window shows monster-by-monster damage
### Cross-Machine Vital Sharing
- WebSocket relay replaces UtilityBelt's localhost-only `VTankFellowHeals`
- Plugin broadcasts its own vitals and consumes peer vitals
- In-game `DxHud` overlay shows peer health/stamina/mana bars with direction arrows
### AI Assistant
- 🤖 chat window in the dashboard backed by `claude -p` running headless on the server
- Read-only access to live game state via 12 MCP tools (live players, inventory cross-search, combat stats, quests, suitbuilder, read-only SQL, etc.)
- Per-browser persistent session, "New Chat" button, history rehydration on reload
- Hardened: dedicated unprivileged Linux user, systemd lockdown, strict tool whitelist, audit log, rate limit. See [AI Assistant section](#ai-assistant-overlord-agent) for the full security stack.
### Discord Integration
- **Rare Monitor Bot** — posts rares (split by common/great) to configured channels
- **Death Alerts** — webhook to `#alerts` when a character's vitae goes from 0 → >0 (rate-limited to one per character per 5 min)
- **Idle Alerts** — webhook after 5 minutes of continuous idle state (caught portals, stuck nav, etc.). The grace period prevents false positives on brief idle blips.
- **Vortex Warning** — bot watches for "whirlwind of vortexes" chat and posts a warning embed
### Portals
- Automatic discovery + 1-hour retention
- Coordinate-deduplicated (rounded to 0.1 precision)
### Stats
- Per-character lifetime kills, deaths, rares, taper counts
- Grafana dashboards (2x2 iframe grid in the stats window)
### Health & Monitoring
- Server uptime + latency + player count from TreeStats.net (checked every 30s)
- Only current state is kept — no historical `server_health_checks` table (removed April 2026 as write-only bloat)
## Requirements ## Requirements
- Docker & Docker Compose (recommended) - Python 3.9 or newer
- OR: Python 3.11+, Node.js 20+, and a PostgreSQL 14+ with TimescaleDB - pip
- (Optional) virtual environment tool (venv)
Python packages:
- fastapi
- uvicorn
- pydantic
- pandas
- matplotlib
## Installation ## Installation
```bash 1. Clone the repository:
git clone git@git.snakedesert.se:SawatoMosswartsEnjoyersClub/MosswartOverlord.git ```bash
cd MosswartOverlord git clone https://github.com/yourusername/dereth-tracker.git
cp .env.example .env # fill in secrets (see Configuration below) cd dereth-tracker
docker compose up -d ```
``` 2. Create and activate a virtual environment:
```bash
### Frontend development loop python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```bash ```
cd frontend 3. Install dependencies:
npm install ```bash
npm run dev # local Vite server pip install fastapi uvicorn pydantic pandas matplotlib
# ...edit files, hot reload... ```
cd ..
bash deploy-frontend.sh # builds + copies to static/ for production serving
```
⚠️ **`npm run build` writes to `static/_build/` but the web server serves from `static/`.** You must run `deploy-frontend.sh` to copy `_build/ → static/`. Otherwise the browser keeps loading the previous bundle.
## Configuration ## Configuration
All secrets go in `.env`: - 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`.
| Variable | Purpose | ## Usage
|---|---|
| `POSTGRES_PASSWORD` | Telemetry DB password |
| `INVENTORY_DB_PASSWORD` | Inventory DB password |
| `SHARED_SECRET` | Plugin auth for `/ws/position` |
| `SECRET_KEY` | Session cookie signing |
| `DISCORD_RARE_BOT_TOKEN` | Bot token for rare monitor |
| `DISCORD_ACLOG_WEBHOOK` | Webhook URL for death/idle alerts |
| `GF_SECURITY_ADMIN_PASSWORD` | Grafana admin |
| `COMMON_RARE_CHANNEL_ID` | Discord channel ID for common rares |
| `GREAT_RARE_CHANNEL_ID` | Discord channel ID for great rares |
| `ACLOG_CHANNEL_ID` | Discord channel ID for the rare bot's status/vortex messages |
| `MONITOR_CHARACTER` | Which character's chat the bot monitors |
The Overlord Agent has its own env file at `/etc/overlord/agent.env` (root:overlord-agent 0640) so it doesn't share the tracker's secrets: Start the server using Uvicorn:
| Variable | Purpose |
|---|---|
| `SECRET_KEY` | Same value as the tracker — validates browser session cookies |
| `AGENT_DB_DSN` | Read-only connection string `postgresql://overlord_agent_ro:<pw>@127.0.0.1:5432/dereth` |
| `TRACKER_URL` | Loopback to the tracker container (default `http://127.0.0.1:8765`) |
| `AGENT_RATE_MAX` | Per-user rate limit (default 60/hour) |
| `AGENT_RATE_WINDOW_S` | Rate-limit window in seconds (default 3600) |
| `AGENT_AUDIT_LOG` | Path to audit JSONL (default `/var/log/overlord-agent/audit.jsonl`) |
| `CLAUDE_TIMEOUT_S` | Max seconds per `claude -p` invocation (default 240) |
## Deploying Changes
Live backend host: `overlord.snakedesert.se` (SSH user `erik`, key-based auth).
### Quick deploy — Python / static file changes
```bash ```bash
ssh erik@overlord.snakedesert.se \ uvicorn main:app --reload --host 0.0.0.0 --port 8000
"cd /home/erik/MosswartOverlord && git pull --ff-only origin master"
# Python changes require a restart:
ssh erik@overlord.snakedesert.se "docker compose restart dereth-tracker"
# Static files (JS/CSS/HTML) are served from the bind-mounted static/ — no restart.
``` ```
⚠️ Uvicorn runs **without** `--reload` in production. Do not add it back — without the `watchfiles` package it falls back to a polling reloader that busy-loops at 100% CPU and eats a whole core. - Live Map: `http://localhost:8000/`
- Analytics Dashboard: `http://localhost:8000/graphs.html`
### React frontend deploy ## API Reference
```bash ### POST /position
cd frontend && npm run build && cd .. Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
bash deploy-frontend.sh
git add static/ && git commit -m "deploy frontend" && git push **Request Body Example:**
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull" ```json
# No container restart needed. {
"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"
}
``` ```
### Full rebuild — Dockerfile / pip package / version stamp changes ### GET /live
Returns active players seen within the last 30 seconds:
```bash ```json
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \ {
git pull --ff-only origin master && \ "players": [ { ... } ]
export BUILD_VERSION=\"\$(date -u +%Y.%-m.%-d.%H%M)-\$(git rev-parse --short HEAD)\" && \ }
docker compose build --no-cache --build-arg BUILD_VERSION=\$BUILD_VERSION dereth-tracker && \
docker compose up -d dereth-tracker"
``` ```
`BUILD_VERSION` is displayed in the sidebar of the live frontend. Format is CalVer: `YYYY.M.D.HHMM-gitshorthash`. ### GET /history
Retrieve historical snapshots with optional `from` and `to` ISO8601 timestamps:
### Overlord Agent deploy ```
GET /history?from=2025-04-22T12:00:00Z&to=2025-04-22T13:00:00Z
Code changes to `agent/` only:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
git pull --ff-only origin master && \
sudo systemctl restart overlord-agent"
journalctl -u overlord-agent -f # tail logs to verify
``` ```
`agent/requirements.txt` changed (new pip deps): Response:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \ ```json
git pull --ff-only origin master && \ {
agent/.venv/bin/pip install -r agent/requirements.txt && \ "data": [ { ... } ]
sudo systemctl restart overlord-agent" }
``` ```
systemd unit changed:
```bash
ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && \
git pull --ff-only origin master && \
sudo cp agent/overlord-agent.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && sudo systemctl restart overlord-agent"
```
First-time install: `bash agent/install.sh` — see `agent/README.md` for the full bootstrap procedure (creating the `overlord-agent` user, copying claude auth, granting filesystem access, populating `/etc/overlord/agent.env`).
## WebSocket Contract
### `/ws/position` (plugin → backend)
Authenticated via `?secret=<SHARED_SECRET>` or `X-Plugin-Secret` header. Accepts JSON frames with a `type` discriminator:
| `type` | Purpose |
|---|---|
| `telemetry` | Position, kills, session metrics (every 2s per character) |
| `vitals` | Health/stamina/mana/vitae percentages |
| `character_stats` | Full attributes/skills/allegiance (every 10 min) |
| `inventory` / `full_inventory` | Complete inventory dump on login |
| `inventory_delta` | Incremental add/update/remove of a single item |
| `equipment_cantrip_state` | Equipped spell effects |
| `portal` | Discovered portal with coordinates |
| `spawn` | Monster spawn observation |
| `chat` | In-game chat line (any channel) |
| `quest` | Quest timer / progress |
| `rare` | Rare item find notification |
| `nearby_objects` | On-demand radar data (nearby entities) |
| `combat_stats` | Session combat snapshot (Mag-Tools parser output) |
| `share_*` | Cross-machine vital/debuff sharing envelopes |
| `dungeon_map` | Dungeon floor tile data for radar overlay |
See `EVENT_FORMATS.json` for exact per-type schemas.
### `/ws/live` (browser → backend)
Session-cookie authenticated (except for internal Docker network clients, which are trusted by IP). Clients can:
- Send `{"type":"subscribe","message_types":["rare","chat"]}` to filter which events they receive. Without subscribing, all types are forwarded (browser default).
- Send `{"player_name":"Larsson","command":"/radar start"}` to route a command to that character's plugin client.
- Send `{"type":"request_dungeon_map","landblock":"..."}` to pull cached dungeon tile data.
Backend pushes the same firehose (subject to subscription filter) to every browser client.
## HTTP API Reference
See `EVENT_FORMATS.json` for event schemas. Major HTTP endpoints:
- `GET /live` — active players seen in the last 30s
- `GET /history?from=…&to=…` — historical telemetry snapshots
- `GET /trails` — recent player trails for the map
- `GET /spawns/heatmap?hours=N` — aggregated spawn density
- `GET /portals` — discovered portals within retention window
- `GET /inventory/{character}` — current inventory (proxied to inventory-service)
- `GET /character-stats/{character}` — full character attributes/skills
- `GET /combat-stats/{character}` — session + lifetime combat stats
- `GET /vital-sharing/peers` — currently-registered vital sharing peers
- `GET /api-version` — build version stamp
- `GET /server-health` — current Coldeve server status + player count
## Frontend ## Frontend
### React v2 (primary, at `/`) - **Live Map**: `static/index.html` Real-time player positions on a map.
- Map-first layout with draggable/resizable windows - **Analytics Dashboard**: `static/graphs.html` Interactive charts powered by [D3.js](https://d3js.org/).
- Code-split bundles: one chunk per window type, lazy-loaded on open
- Window types: Chat, Stats, Inventory, Character, Radar, CombatStats, CombatPicker, Issues, VitalSharing, QuestStatus, PlayerDashboard
- Per-character inventory version counter — an open inventory window refreshes 2s after its own character's last `inventory_delta`, ignoring unrelated traffic
- Direct DOM pan/zoom on the map (no React state per frame)
- Service worker caches a small whitelist of static assets
- Version badge in the sidebar confirms which build is loaded
### Classic v1 (preserved at `/classic`)
The original vanilla JS frontend with element-pooling optimization is kept for fallback and reference.
## AI Assistant (Overlord Agent)
A draggable chat window in the dashboard (🤖 Assistant button). Powered by `claude -p` running headless on the server, with read-only access to live game state via an MCP server.
### Architecture
- **Host-side service** (`agent/`, systemd unit `overlord-agent`) runs OUTSIDE Docker because the `claude` CLI binary lives on the host (`/home/erik/.local/bin/claude`) and depends on host-side authentication credentials.
- **Dedicated UNIX user** (`overlord-agent`, system account, `/var/lib/overlord-agent` home, no shell) — kernel-level isolation from the operator's `erik` account. Cannot read `/home/erik/.claude`, `~/.ssh`, `.bash_history`, `.env`, etc.
- **MCP stdio server** (`agent/mcp_overlord.py`) exposes 12 tools that wrap the tracker's HTTP endpoints + read-only DB queries. Claude only sees these tools; no `Bash`, `Read`, `Write`, etc.
- **Frontend** (`AgentWindow.tsx`) — per-browser session UUID in localStorage, "New Chat" button, on-mount rehydration from `/agent/sessions/{id}/history`.
### MCP tools available to the assistant
`get_live_players`, `get_player_state`, `get_combat_stats`, `get_equipment_cantrips`, `get_inventory`, `get_inventory_search`, `search_items` (cross-character), `get_recent_rares`, `get_quest_status`, `get_server_health`, `query_telemetry_db` (read-only SQL via sqlglot parser + GRANT-SELECT-only PG role), `suitbuilder_search`. Plus `WebFetch(domain:acpedia.org)` for AC info lookups.
### Security stack (defense-in-depth)
1. **Cookie auth** on `/agent/ask` (same session cookie the tracker issues)
2. **Per-user rate limit** (60 req/h default) and **concurrency cap** (1 in-flight)
3. **JSONL audit log** at `/var/log/overlord-agent/audit.jsonl` (every prompt + result)
4. **CLI flags**`--allowed-tools` (just our 12 MCP tools), `--disallowed-tools` (Bash, Write, Read, Edit, Agent, ToolSearch, Monitor, scheduling, Gmail/Drive/Calendar, etc.), `--permission-mode dontAsk`
5. **`/var/lib/overlord-agent/.claude/settings.json`** — strict deny rules (server-side only, NOT in repo)
6. **System-prompt scope rules** in `CLAUDE.md` — instruct the model not to probe, not to suggest workarounds
7. **SQL parser** (`sqlglot`) rejects any non-SELECT statement on `query_telemetry_db`
8. **Read-only PG role** `overlord_agent_ro` (GRANT SELECT only) — even a parser bypass can't mutate
9. **systemd hardening**`ProtectSystem=strict`, `ProtectHome=read-only`, `InaccessiblePaths=/etc/shadow,/root,~/.ssh,…`, `NoNewPrivileges=true`, `CapabilityBoundingSet=` (empty), `PrivateTmp=true`, `PrivateDevices=true`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallFilter=@system-service ~@privileged ~@reboot ~@mount`, `MemoryMax=512M`, `TasksMax=128`
10. **Secrets out of /home**`/etc/overlord/agent.env` (root:overlord-agent 0640) for SECRET_KEY + AGENT_DB_DSN
### Files
| Path | What |
|------|------|
| `agent/service.py` | FastAPI app: `/agent/health`, `/agent/sessions/new`, `/agent/ask`, `/agent/sessions/{id}/history` |
| `agent/auth.py` | Session cookie validation (mirrors `main.py:1013-1019`) |
| `agent/claude_wrapper.py` | `asyncio.create_subprocess_exec("claude", "-p", …)` with allowed/disallowed-tools |
| `agent/tools.py` | Pure tool implementations |
| `agent/mcp_overlord.py` | MCP stdio server registering tools |
| `agent/sql/0001_overlord_agent_ro.sql` | Read-only PG role |
| `agent/overlord-agent.service` | systemd unit (the hardening directives) |
| `agent/install.sh` | venv + systemd setup |
| `agent/README.md` | Operator's deeper reference |
| `.mcp.json` (repo root) | Project-level MCP config Claude Code auto-loads |
| `CLAUDE.md` "Overlord Assistant Mode" section | System-prompt briefing |
### Routing
nginx forwards `/api/agent/*` to `127.0.0.1:8767` (the host-side service) with a 300s read/send timeout (suitbuilder runs can be slow). Other `/api/*` continues to the dereth-tracker container at `127.0.0.1:8765`.
### Cost / quota
Subscription auth (no API key); per-call cost is informational only. Each `/agent/ask` invocation = one `claude -p` subprocess with shared session cache. Reactive only — no background polling, no scheduled tasks.
## Database Schema ## Database Schema
### Telemetry DB (`dereth`, TimescaleDB) - **telemetry_log**: Stored history of snapshots.
- **live_state**: Current snapshot per character (upserted).
| Table | Type | Retention | Purpose | ## Sample Payload
|---|---|---|---|
| `telemetry_events` | hypertable | 30 days | Position/stats snapshots |
| `spawn_events` | hypertable | 7 days | Monster spawn observations (heatmap source) |
| `rare_events` | regular | forever | Rare find history |
| `portals` | regular | 1 hour | Discovered portals, dedup by rounded coords |
| `char_stats` | regular | forever | Per-character lifetime kill total |
| `rare_stats` | regular | forever | Per-character lifetime rare total |
| `rare_stats_sessions` | regular | forever | Per-session rare count |
| `combat_stats` | regular | forever | Lifetime combat accumulator |
| `combat_stats_sessions` | regular | forever | Per-session combat snapshots |
| `character_stats` | regular | forever | Latest full stats JSON per character |
| `server_status` | regular | forever | Current Coldeve server state (single row) |
### Inventory DB (`inventory_db`, PostgreSQL) See `test.json` for an example telemetry snapshot.
Normalized schema: `items`, `item_combat_stats`, `item_requirements`, `item_enhancements`, `item_ratings`, `item_spells`, `item_raw_data`.
`items.container_id` stores the in-game ID of the container holding the item (0 = character body). The frontend groups items into packs by this ID.
## Operations & Health
### PostgreSQL tuning
`dereth-db` runs with explicit memory overrides in `docker-compose.yml`:
- `shared_buffers=8GB` (was 96GB via auto-tune on a 32GB host — caused thrashing)
- `effective_cache_size=16GB`
- `work_mem=16MB`, `maintenance_work_mem=1GB`
- `max_wal_size=4GB`
### Retention policies
- `telemetry_events`: 30-day drop, daily
- `spawn_events`: 7-day drop, daily
- `portals`: 1-hour cleanup (background task in `main.py`)
- `server_health_checks`: **removed** — was write-only, 850K rows of nothing
### Log levels
Both `dereth-tracker` and `inventory-service` run at `LOG_LEVEL=INFO`. Do not set to `DEBUG` in production — it dumps full inventory_delta payloads for every item update (hundreds of KB/sec).
### Host (Proxmox VM)
- 6 vCPU, 32 GiB RAM (of which ~30 GiB is normally free under current load)
- Live host: `overlord.snakedesert.se`
- Reverse proxy: Nginx on the host terminates TLS and strips the `/api/` prefix before forwarding to port 8765
### Debug commands
```bash
docker ps
docker logs mosswartoverlord-dereth-tracker-1 --tail 100
docker logs mosswartoverlord-inventory-service-1 --tail 100
docker logs mosswartoverlord-discord-rare-monitor-1 --tail 100
docker exec dereth-db psql -U postgres -d dereth
```
## Contributing ## Contributing
Contributions welcome. Please: Contributions are welcome! Feel free to open issues or submit pull requests.
- Keep cross-repo protocol changes additive (new optional fields > renames/removes)
- Update both this README and `CLAUDE.md` when workflows change
- Test end-to-end: plugin → backend → browser for any new event type
For detailed architecture notes and ongoing investigations, see `CLAUDE.md` and `docs/plans/`.

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,51 +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:996-998
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please")
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,113 +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).
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,401 +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),
)
# 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 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

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