Compare commits
No commits in common. "feature/async-timescale" and "master" have entirely different histories.
feature/as
...
master
42
Dockerfile
|
|
@ -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"]
|
||||
4
Makefile
|
|
@ -1,4 +1,2 @@
|
|||
# Reformat Python code using Black formatter
|
||||
.PHONY: reformat
|
||||
reformat:
|
||||
black *.py
|
||||
black *py
|
||||
|
|
|
|||
317
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
- [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)
|
||||
- [Frontend](#frontend)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Sample Payload](#sample-payload)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Overview
|
||||
|
||||
This project provides:
|
||||
- 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 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.
|
||||
- An analytics dashboard for visualizing kills and session metrics.
|
||||
|
||||
## 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 /history**: Retrieve historical telemetry data with optional time filtering.
|
||||
- **GET /debug**: Health check endpoint.
|
||||
- **Live Map**: Interactive map interface with panning, zooming, and sorting.
|
||||
- **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.
|
||||
- **Analytics Dashboard**: Interactive charts for kills over time and kills per hour using D3.js.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or newer (only if running without Docker)
|
||||
- pip (only if running without Docker)
|
||||
- Docker & Docker Compose (recommended)
|
||||
- Python 3.9 or newer
|
||||
- pip
|
||||
- (Optional) virtual environment tool (venv)
|
||||
|
||||
Python packages (if using local virtualenv):
|
||||
Python packages:
|
||||
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- pydantic
|
||||
- databases
|
||||
- asyncpg
|
||||
- sqlalchemy
|
||||
- websockets # required for sample data generator
|
||||
- pandas
|
||||
- matplotlib
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -80,128 +60,33 @@ Python packages (if using local virtualenv):
|
|||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install fastapi uvicorn pydantic websockets
|
||||
pip install fastapi uvicorn pydantic pandas matplotlib
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Configure the plugin shared secret via the `SHARED_SECRET` environment variable (default in code: `"your_shared_secret"`).
|
||||
- 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`.
|
||||
- Update the `SHARED_SECRET` in `main.py` to match your plugin (default: `"your_shared_secret"`).
|
||||
- The SQLite database file `dereth.db` is created in the project root. To change the path, edit `DB_FILE` in `db.py`.
|
||||
|
||||
## Usage
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
# Grafana Dashboard UI
|
||||
```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; }`.
|
||||
- Live Map: `http://localhost:8000/`
|
||||
- Analytics Dashboard: `http://localhost:8000/graphs.html`
|
||||
|
||||
## API Reference
|
||||
|
||||
### WebSocket /ws/position
|
||||
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:
|
||||
### POST /position
|
||||
Submit a JSON telemetry snapshot. Requires header `X-Plugin-Secret: <shared_secret>`.
|
||||
|
||||
**Request Body Example:**
|
||||
```json
|
||||
{
|
||||
"type": "telemetry",
|
||||
"character_name": "Dunking Rares",
|
||||
"char_tag": "moss",
|
||||
"session_id": "dunk-20250422-xyz",
|
||||
|
|
@ -211,62 +96,14 @@ After connecting, send JSON messages matching the `TelemetrySnapshot` schema. Fo
|
|||
"z": 10.2,
|
||||
"kills": 42,
|
||||
"deaths": 1,
|
||||
"rares_found": 2,
|
||||
"prismatic_taper_count": 17,
|
||||
"vt_state": "Combat",
|
||||
"kills_per_hour": "N/A",
|
||||
"onlinetime": "00:05:00"
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
Returns active players seen within the last 30 seconds:
|
||||
|
||||
|
|
@ -294,119 +131,17 @@ Response:
|
|||
## Frontend
|
||||
|
||||
- **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
|
||||
|
||||
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
|
||||
aggregate character statistics, and a separate inventory database for equipment management.
|
||||
- **telemetry_log**: Stored history of snapshots.
|
||||
- **live_state**: Current snapshot per character (upserted).
|
||||
|
||||
### Telemetry Database Tables:
|
||||
## Sample Payload
|
||||
|
||||
- **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)
|
||||
See `test.json` for an example telemetry snapshot.
|
||||
|
||||
## Contributing
|
||||
|
||||
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
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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
|
||||
from typing import Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Local SQLite database file name (used when running without TimescaleDB)
|
||||
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:
|
||||
"""
|
||||
Initialize local SQLite database schema for telemetry logging.
|
||||
|
||||
- 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)
|
||||
"""Create tables if they do not exist (extended with kills_per_hour and onlinetime)."""
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
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(
|
||||
"""
|
||||
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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_state (
|
||||
|
|
@ -103,27 +59,11 @@ def init_db() -> None:
|
|||
|
||||
|
||||
def save_snapshot(data: Dict) -> None:
|
||||
"""
|
||||
Save a telemetry snapshot into the local SQLite database.
|
||||
|
||||
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)
|
||||
"""Insert snapshot into history and upsert into live_state (with new fields)."""
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
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(
|
||||
"""
|
||||
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(
|
||||
"""
|
||||
INSERT INTO live_state (
|
||||
|
|
|
|||
269
db_async.py
|
|
@ -1,269 +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
|
||||
|
||||
# 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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |