Compare commits
34 commits
master
...
feature/as
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6adb88e0b1 | ||
|
|
e6db0f094c | ||
|
|
fbb6f61705 | ||
|
|
6c646719dd | ||
|
|
e7ca39318f | ||
|
|
4d19e29847 | ||
|
|
00ef1d1f4b | ||
|
|
7ff94b59a8 | ||
|
|
dffd295091 | ||
|
|
72de9b0f7f | ||
|
|
ca12f4807b | ||
|
|
80a0a16bab | ||
|
|
1febf6e918 | ||
|
|
57a2384511 | ||
|
|
09a6cd4946 | ||
|
|
10c51f6825 | ||
|
|
f218350959 | ||
|
|
fdf9f04bc6 | ||
|
|
81ec59d220 | ||
|
|
4de85b8db4 | ||
|
|
add24e5c9d | ||
|
|
7845570819 | ||
|
|
d9b3b403da | ||
|
|
09404da121 | ||
|
|
b2f649a489 | ||
|
|
f86ad9a542 | ||
|
|
c418221575 | ||
|
|
c20d54d037 | ||
|
|
d396942deb | ||
|
|
0313c2a2ae | ||
|
|
b94f064118 | ||
|
|
337eff56aa | ||
|
|
73ae756e5c | ||
|
|
a121d57a13 |
42
Dockerfile
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# 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"]
|
||||||
4
Makefile
|
|
@ -1,2 +1,4 @@
|
||||||
|
# Reformat Python code using Black formatter
|
||||||
|
.PHONY: reformat
|
||||||
reformat:
|
reformat:
|
||||||
black *py
|
black *.py
|
||||||
|
|
|
||||||
317
README.md
|
|
@ -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 SQLite database, and provides both a live map interface and an analytics dashboard.
|
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.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
|
|
@ -12,39 +12,59 @@ 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.
|
||||||
- SQLite-based storage for snapshots and live state.
|
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
|
||||||
- A live, interactive map using static HTML, CSS, and JavaScript.
|
- A live, interactive map using static HTML, CSS, and JavaScript.
|
||||||
- An analytics dashboard for visualizing kills and session metrics.
|
- A comprehensive inventory management system with search capabilities.
|
||||||
|
- 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
|
||||||
|
|
||||||
- **POST /position**: Submit a telemetry snapshot (protected by a shared secret).
|
- **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (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.
|
||||||
- **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
|
- **Inventory Management**:
|
||||||
|
- 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
|
- Python 3.9 or newer (only if running without Docker)
|
||||||
- pip
|
- pip (only if running without Docker)
|
||||||
- (Optional) virtual environment tool (venv)
|
- Docker & Docker Compose (recommended)
|
||||||
|
|
||||||
Python packages:
|
Python packages (if using local virtualenv):
|
||||||
|
|
||||||
- fastapi
|
- fastapi
|
||||||
- uvicorn
|
- uvicorn
|
||||||
- pydantic
|
- pydantic
|
||||||
- pandas
|
- databases
|
||||||
- matplotlib
|
- asyncpg
|
||||||
|
- sqlalchemy
|
||||||
|
- websockets # required for sample data generator
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -60,33 +80,128 @@ Python packages:
|
||||||
```
|
```
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
pip install fastapi uvicorn pydantic pandas matplotlib
|
pip install fastapi uvicorn pydantic websockets
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
|
- Configure the plugin shared secret via the `SHARED_SECRET` environment variable (default in code: `"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`.
|
- The database connection is controlled by the `DATABASE_URL` environment variable (e.g. `postgresql://postgres:password@db:5432/dereth`).
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
- Live Map: `http://localhost:8000/`
|
# Grafana Dashboard UI
|
||||||
- Analytics Dashboard: `http://localhost:8000/graphs.html`
|
```nginx
|
||||||
|
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
|
||||||
|
|
||||||
### POST /position
|
### WebSocket /ws/position
|
||||||
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
|
Stream telemetry snapshots over a WebSocket connection. Provide your shared secret either as a query parameter or WebSocket header:
|
||||||
|
|
||||||
|
```
|
||||||
|
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",
|
||||||
|
|
@ -96,14 +211,62 @@ Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secr
|
||||||
"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:
|
||||||
|
|
||||||
|
|
@ -131,17 +294,119 @@ 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.
|
||||||
- **Analytics Dashboard**: `static/graphs.html` – Interactive charts powered by [D3.js](https://d3js.org/).
|
- **Inventory Search**: `static/inventory.html` – Search and browse character inventories with advanced filtering.
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
- **telemetry_log**: Stored history of snapshots.
|
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
|
||||||
- **live_state**: Current snapshot per character (upserted).
|
aggregate character statistics, and a separate inventory database for equipment management.
|
||||||
|
|
||||||
## Sample Payload
|
### Telemetry Database Tables:
|
||||||
|
|
||||||
See `test.json` for an example telemetry snapshot.
|
- **telemetry_events** (hypertable):
|
||||||
|
- `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
|
||||||
|
|
|
||||||
39
alembic.ini
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
; 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
|
||||||
64
alembic/env.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""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()
|
||||||
25
alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<%#
|
||||||
|
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
|
||||||
5
alembic/versions/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""
|
||||||
|
This directory will hold Alembic migration scripts.
|
||||||
|
Each migration filename should follow the naming convention:
|
||||||
|
<revision_id>_<slug>.py
|
||||||
|
"""
|
||||||
80
db.py
|
|
@ -1,15 +1,59 @@
|
||||||
|
"""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)."""
|
"""
|
||||||
conn = sqlite3.connect(DB_FILE)
|
Initialize local SQLite database schema for telemetry logging.
|
||||||
c = conn.cursor()
|
|
||||||
|
|
||||||
# History log
|
- 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()
|
||||||
|
# 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
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS telemetry_log (
|
CREATE TABLE IF NOT EXISTS telemetry_log (
|
||||||
|
|
@ -32,7 +76,7 @@ def init_db() -> None:
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Live snapshot (upsert)
|
# Create live_state table for upserts of the most recent snapshot per character
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS live_state (
|
CREATE TABLE IF NOT EXISTS live_state (
|
||||||
|
|
@ -59,11 +103,27 @@ 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)."""
|
"""
|
||||||
conn = sqlite3.connect(DB_FILE)
|
Save a telemetry snapshot into the local SQLite database.
|
||||||
c = conn.cursor()
|
|
||||||
|
|
||||||
# Insert full history row
|
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()
|
||||||
|
# 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
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO telemetry_log (
|
INSERT INTO telemetry_log (
|
||||||
|
|
@ -91,7 +151,7 @@ def save_snapshot(data: Dict) -> None:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Upsert into live_state
|
# Upsert (insert or update) the latest snapshot into live_state table
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO live_state (
|
INSERT INTO live_state (
|
||||||
|
|
|
||||||
269
db_async.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
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
|
||||||
27
discord-rare-monitor/Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# 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"]
|
||||||
95
discord-rare-monitor/README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# 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
|
||||||
67
discord-rare-monitor/config.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
1181
discord-rare-monitor/discord_rare_monitor.py
Normal file
55
discord-rare-monitor/icon_mapping.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/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}")
|
||||||
294
discord-rare-monitor/icon_name_mapping.json
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
discord-rare-monitor/icons/Adepts_Fervor_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Adherents_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Alchemists_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Aquamarine_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Archers_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Aristocrats_Bracelet_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Artificers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Artists_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Assassins_Whisper_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Astyrrians_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Band_of_Elemental_Harmony_Icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
discord-rare-monitor/icons/Baton_of_Tirethas_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Bearded_Axe_of_Souia-Vey_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Ben_Tens_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Berzerkers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Black_Cloud_Bow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Black_Garnet_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Black_Opal_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Black_Thistle_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Bloodmark_Crossbow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Bracelet_of_Binding_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Bracers_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Bradors_Frozen_Eye_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Brawlers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Canfield_Cleaver_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Casino_Exquisite_Keyring_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Champions_Demise_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Chefs_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Chitin_Cracker_Icon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
discord-rare-monitor/icons/Circle_of_Pure_Thought_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Converters_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Corruptors_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Corsairs_Arc_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Count_Renaris_Equalizer_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Dart_Flicker_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Deaths_Grip_Staff_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Decapitators_Blade_Icon.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
discord-rare-monitor/icons/Deceivers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Defiler_of_Milantos_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Deru_Limb_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Desert_Wyrm_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Dodgers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Dragonspine_Bow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Dread_Marauder_Shield_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Dreamseer_Bangle_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Drifters_Atlatl_Icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
discord-rare-monitor/icons/Dripping_Death_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Duelists_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Dusk_Coat_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Dusk_Leggings_Icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
discord-rare-monitor/icons/Ebonwood_Shortbow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Elysas_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Emerald_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Enchanters_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Eternal_Health_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
discord-rare-monitor/icons/Eternal_Mana_Charge_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Eternal_Mana_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Eternal_Stamina_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Evaders_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Executors_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Eye_of_Muramm_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Feathered_Razor_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Fire_Opal_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Fist_of_Three_Principles_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Fletchers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Footmans_Boots_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gauntlets_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Boots_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Bracers_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Breastplate_Icon.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Gauntlets_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Girth_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Greaves_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Mitre_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Pauldrons_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Tassets_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Gelids_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Girth_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Golden_Snake_Choker_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Greaves_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Guardian_of_Pwyll_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Heart_of_Darkest_Flame_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Helm_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |