Compare commits

..

1 commit

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

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
.venv
__pycache__

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.

140
CLAUDE.md
View file

@ -1,140 +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)

View file

@ -1,42 +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
## 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
## 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","--reload","--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

317
README.md
View file

@ -1,6 +1,6 @@
# Dereth Tracker # Dereth Tracker
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, provides a live map interface, and includes a comprehensive inventory management system for tracking and searching character equipment. 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.
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
@ -12,59 +12,39 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
- [API Reference](#api-reference) - [API Reference](#api-reference)
- [Frontend](#frontend) - [Frontend](#frontend)
- [Database Schema](#database-schema) - [Database Schema](#database-schema)
- [Sample Payload](#sample-payload)
- [Contributing](#contributing) - [Contributing](#contributing)
## Overview ## Overview
This project provides: This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data. - A FastAPI backend with endpoints for receiving and querying telemetry data.
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats. - SQLite-based storage for snapshots and live state.
- A live, interactive map using static HTML, CSS, and JavaScript. - A live, interactive map using static HTML, CSS, and JavaScript.
- A comprehensive inventory management system with search capabilities. - An analytics dashboard for visualizing kills and session metrics.
- Real-time inventory updates via WebSocket when characters log in/out.
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
## Features ## Features
- **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret). - **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
- **GET /live**: Fetch active players seen in the last 30 seconds. - **GET /live**: Fetch active players seen in the last 30 seconds.
- **GET /history**: Retrieve historical telemetry data with optional time filtering. - **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint. - **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting. - **Live Map**: Interactive map interface with panning, zooming, and sorting.
- **Inventory Management**: - **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
- Real-time inventory updates via WebSocket on character login/logout
- Advanced search across all character inventories
- Filter by character, equipment type, material, stats, and more
- Sort by any column with live results
- Track item properties including spells, armor level, damage ratings
- **Suitbuilder**:
- Equipment optimization across multiple character inventories
- Constraint-based search for optimal armor combinations
- Support for primary and secondary armor sets
- Real-time streaming results during long-running searches
- **Portal Tracking**:
- Automatic discovery and tracking of in-game portals
- 1-hour retention for discovered portals
- Coordinate-based uniqueness (rounded to 0.1 precision)
- Real-time portal updates on the map interface
- **Discord Rare Monitor Bot**: Monitors rare discoveries and posts filtered notifications to Discord channels
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
## Requirements ## Requirements
- Python 3.9 or newer (only if running without Docker) - Python 3.9 or newer
- pip (only if running without Docker) - pip
- Docker & Docker Compose (recommended) - (Optional) virtual environment tool (venv)
Python packages (if using local virtualenv): Python packages:
- fastapi - fastapi
- uvicorn - uvicorn
- pydantic - pydantic
- databases - pandas
- asyncpg - matplotlib
- sqlalchemy
- websockets # required for sample data generator
## Installation ## Installation
@ -80,128 +60,33 @@ Python packages (if using local virtualenv):
``` ```
3. Install dependencies: 3. Install dependencies:
```bash ```bash
pip install fastapi uvicorn pydantic websockets pip install fastapi uvicorn pydantic pandas matplotlib
``` ```
## Configuration ## Configuration
- Configure the plugin shared secret via the `SHARED_SECRET` environment variable (default in code: `"your_shared_secret"`). - Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
- The database connection is controlled by the `DATABASE_URL` environment variable (e.g. `postgresql://postgres:password@db:5432/dereth`). - The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
By default, when using Docker Compose, a TimescaleDB container is provisioned for you.
- If you need to tune Timescale or Postgres settings (retention, checkpoint, etc.), set the corresponding `DB_*` environment variables as documented in `docker-compose.yml`.
## Usage ## Usage
### Using Docker (Recommended)
1. Build and start all services:
```bash
docker compose up -d
```
2. Rebuild container after code changes:
```bash
docker compose build --no-cache dereth-tracker
docker compose up -d dereth-tracker
```
3. View logs:
```bash
docker logs mosswartoverlord-dereth-tracker-1
docker logs dereth-db
```
### Without Docker
Start the server using Uvicorn: Start the server using Uvicorn:
```bash ```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000 uvicorn main:app --reload --host 0.0.0.0 --port 8000
``` ```
# Grafana Dashboard UI - Live Map: `http://localhost:8000/`
```nginx - Analytics Dashboard: `http://localhost:8000/graphs.html`
location /grafana/ {
# Optional: require basic auth on the Grafana UI
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Inject Grafana service account token for anonymous panel embeds
proxy_set_header Authorization "Bearer <YOUR_SERVICE_ACCOUNT_TOKEN>";
# WebSocket support (for live panels)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
## NGINX Proxy Configuration
If you cannot reassign the existing `/live` and `/trails` routes, you can namespace this service under `/api` (or any other prefix) and configure NGINX accordingly. Be sure to forward WebSocket upgrade headers so that `/ws/live` and `/ws/position` continue to work. Example:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8765/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
```
Then the browser client (static/script.js) will fetch `/api/live/` and `/api/trails/` to reach this new server.
- Live Map: `http://localhost:8000/` (or `http://<your-domain>/api/` if behind a prefix)
- Grafana UI: `http://localhost:3000/grafana/` (or `http://<your-domain>/grafana/` if proxied under that path)
### Frontend Configuration
- In `static/script.js`, the constant `API_BASE` controls where live/trails data and WebSocket `/ws/live` are fetched. By default:
```js
const API_BASE = '/api';
```
Update `API_BASE` if you mount the service under a different path or serve it at root.
### Debugging WebSockets
- Server logs now print every incoming WebSocket frame in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw-payload>` for plugin messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for browser messages on `/ws/live`
- Use these logs to verify messages and troubleshoot handshake failures.
### Styling Adjustments
- Chat input bar is fixed at the bottom of the chat window (`.chat-form { position:absolute; bottom:0; }`).
- Input text and placeholder are white for readability (`.chat-input, .chat-input::placeholder { color:#fff; }`).
- Incoming chat messages forced white via `.chat-messages div { color:#fff !important; }`.
## API Reference ## API Reference
### WebSocket /ws/position ### POST /position
Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header: Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
```
ws://<host>:<port>/ws/position?secret=<shared_secret>
```
or
```
X-Plugin-Secret: <shared_secret>
```
After connecting, send JSON messages matching the `TelemetrySnapshot` schema. For example:
**Request Body Example:**
```json ```json
{ {
"type": "telemetry",
"character_name": "Dunking Rares", "character_name": "Dunking Rares",
"char_tag": "moss", "char_tag": "moss",
"session_id": "dunk-20250422-xyz", "session_id": "dunk-20250422-xyz",
@ -211,62 +96,14 @@ After connecting, send JSON messages matching the `TelemetrySnapshot` schema. Fo
"z": 10.2, "z": 10.2,
"kills": 42, "kills": 42,
"deaths": 1, "deaths": 1,
"rares_found": 2,
"prismatic_taper_count": 17, "prismatic_taper_count": 17,
"vt_state": "Combat", "vt_state": "Combat",
"kills_per_hour": "N/A", "kills_per_hour": "N/A",
"onlinetime": "00:05:00" "onlinetime": "00:05:00"
}
```
Each message above is sent as its own JSON object over the WebSocket (one frame per event). When you want to report a rare spawn, send a standalone `rare` event instead of embedding rare counts in telemetry. For example:
```json
{
"type": "rare",
"timestamp": "2025-04-22T13:48:00Z",
"character_name": "MyCharacter",
"name": "Golden Gryphon",
"ew": 150.5,
"ns": 350.7,
"z": 5.0,
"additional_info": "first sighting of the day"
}
```
### Chat messages
You can also send chat envelopes over the same WebSocket to display messages in the browser. Fields:
- `type`: must be "chat"
- `character_name`: target player name
- `text`: message content
- `color` (optional): CSS color string (e.g. "#ff8800"); if sent as an integer (0xRRGGBB), it will be converted to hex.
Example chat payload:
```json
{
"type": "chat",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88f"
} }
``` ```
## Event Payload Formats
For a complete reference of JSON payloads accepted by the backend (over `/ws/position`), see the file `EVENT_FORMATS.json` in the project root. It contains example schemas for:
- **Telemetry events** (`type`: "telemetry")
- **Spawn events** (`type`: "spawn")
- **Chat events** (`type`: "chat")
- **Rare events** (`type`: "rare")
- **Inventory events** (`type`: "inventory")
Notes on payload changes:
- Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0.
Coordinates (`ew`, `ns`, `z`) may be sent as JSON numbers or strings; the backend will coerce them to floats.
- Telemetry events have removed the `latency_ms` field; please omit it from your payloads.
- Inventory events are sent automatically on character login/logout containing complete inventory data.
Each entry shows all required and optional fields, their types, and example values.
### GET /live ### GET /live
Returns active players seen within the last 30 seconds: Returns active players seen within the last 30 seconds:
@ -294,119 +131,17 @@ Response:
## Frontend ## Frontend
- **Live Map**: `static/index.html` Real-time player positions on a map. - **Live Map**: `static/index.html` Real-time player positions on a map.
- **Inventory Search**: `static/inventory.html` Search and browse character inventories with advanced filtering. - **Analytics Dashboard**: `static/graphs.html` Interactive charts powered by [D3.js](https://d3js.org/).
## Database Schema ## Database Schema
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data, - **telemetry_log**: Stored history of snapshots.
aggregate character statistics, and a separate inventory database for equipment management. - **live_state**: Current snapshot per character (upserted).
### Telemetry Database Tables: ## Sample Payload
- **telemetry_events** (hypertable): See `test.json` for an example telemetry snapshot.
- `id` (PK, serial)
- `character_name` (text, indexed)
- `char_tag` (text, nullable)
- `session_id` (text, indexed)
- `timestamp` (timestamptz, indexed)
- `ew`, `ns`, `z` (float)
- `kills`, `deaths`, `rares_found`, `prismatic_taper_count` (integer)
- `kills_per_hour` (float)
- `onlinetime`, `vt_state` (text)
- Optional metrics: `mem_mb`, `cpu_pct`, `mem_handles`, `latency_ms` (float)
- **char_stats**:
- `character_name` (text, PK)
- `total_kills` (integer)
- **rare_stats**:
- `character_name` (text, PK)
- `total_rares` (integer)
- **rare_stats_sessions**:
- `character_name`, `session_id` (composite PK)
- `session_rares` (integer)
- **spawn_events**:
- `id` (PK, serial)
- `character_name` (text)
- `mob` (text)
- `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float)
- **rare_events**:
- `id` (PK, serial)
- `character_name` (text)
- `name` (text)
- `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float)
- **portals**:
- `id` (PK, serial)
- `portal_name` (text)
- `ns`, `ew`, `z` (float coordinates)
- `discovered_at` (timestamptz, indexed)
- `discovered_by` (text)
- Unique constraint: `ROUND(ns::numeric, 1), ROUND(ew::numeric, 1)`
### Inventory Database Tables:
- **items**:
- `id` (PK, serial)
- `character_name` (text, indexed)
- `item_id` (bigint)
- `name` (text)
- `object_class` (integer)
- `icon`, `value`, `burden` (integer)
- `current_wielded_location`, `bonded`, `attuned`, `unique` (various)
- `timestamp` (timestamptz)
- **item_combat_stats**:
- `item_id` (FK to items.id)
- `armor_level`, `max_damage` (integer)
- `damage_bonus`, `attack_bonus` (float)
- Various defense bonuses
- **item_enhancements**:
- `item_id` (FK to items.id)
- `material` (varchar)
- `item_set` (varchar)
- `tinks`, `workmanship` (integer/float)
- **item_spells**:
- `item_id` (FK to items.id)
- `spell_id` (integer)
- `spell_name` (text)
- `is_legendary`, `is_epic` (boolean)
- **item_raw_data**:
- `item_id` (FK to items.id)
- `int_values`, `double_values`, `string_values`, `bool_values` (JSONB)
- `original_json` (JSONB)
## Contributing ## Contributing
Contributions are welcome! Feel free to open issues or submit pull requests. Contributions are welcome! Feel free to open issues or submit pull requests.
## Roadmap & TODO
For detailed tasks, migration steps, and future enhancements, see [TODO.md](TODO.md).
### Local Development Database
This service uses PostgreSQL with the TimescaleDB extension. You can configure local development using the provided Docker Compose setup or connect to an external instance:
1. PostgreSQL/TimescaleDB via Docker Compose (recommended):
- Pros:
- Reproducible, isolated environment out-of-the-box
- No need to install Postgres locally
- Aligns development with production setups
- Cons:
- Additional resource usage (memory, CPU)
- Slightly more complex Docker configuration
2. External PostgreSQL instance:
- Pros:
- Leverages existing infrastructure
- No Docker overhead
- Cons:
- Requires manual setup and Timescale extension
- Less portable for new contributors

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,304 +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
# 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),
)
# 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 tables
server_health_checks = Table(
# Time-series data for server health checks
"server_health_checks",
metadata,
Column("id", Integer, primary_key=True),
Column("server_name", String, nullable=False, index=True),
Column("server_address", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, default=sqlalchemy.func.now()),
Column("status", String(10), nullable=False), # 'up' or 'down'
Column("latency_ms", Float, nullable=True),
Column("player_count", Integer, nullable=True),
)
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),
)
# Index for efficient server health check queries
Index(
'ix_server_health_checks_name_ts',
server_health_checks.c.server_name,
server_health_checks.c.timestamp.desc()
)
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),
)
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}")
# 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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

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.8 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.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.6 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.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

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