Compare commits

...
Sign in to create a new pull request.

34 commits

Author SHA1 Message Date
erik
6adb88e0b1 fixed DB issue 2025-09-25 19:32:59 +00:00
erik
e6db0f094c Fixed error portal insert 2025-09-23 20:13:35 +00:00
erik
fbb6f61705 Updated README.md 2025-09-22 18:25:07 +00:00
erik
6c646719dd reduced duplicate insert errors of portals, still present because of two players disovering the same portal at the same time, other changes to inventory 2025-09-22 18:21:04 +00:00
erik
e7ca39318f Fix score-based ordering in suitbuilder frontend
Updated JavaScript to maintain score ordering during streaming search:
- Replace addSuitToResults() with insertSuitInScoreOrder()
- Add regenerateResultsDisplay() to maintain proper DOM ordering
- Medal assignment (🥇🥈🥉) now based on score ranking, not arrival order
- Suits with highest scores now always appear at top during live search
- Updated displaySuitResults() to sort by score before displaying

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-03 20:02:01 +00:00
erik
4d19e29847 major fixes for inventory 2025-07-02 10:29:36 +00:00
erik
00ef1d1f4b Now with sawato life chat 2025-06-25 08:50:08 +00:00
erik
7ff94b59a8 fixed portals 2025-06-24 19:13:31 +00:00
erik
dffd295091 added portals, quest tracking, discord monitor etc etc 2025-06-23 19:26:44 +00:00
erik
72de9b0f7f fixed server status uptime for coldeve 2025-06-21 21:00:23 +00:00
erik
ca12f4807b added server tracking plus server metrics 2025-06-20 07:17:01 +00:00
erik
80a0a16bab Debug and inventory 2025-06-19 17:46:19 +00:00
erik
1febf6e918 Fix suit building constraint satisfaction logic
- Rewrite armor and accessory filtering to only include items that contribute to constraints
- Update jewelry and clothing scoring to reject items that don't meet constraints
- Modify suit completion to leave slots empty instead of filling with non-contributing items
- Update scoring to heavily penalize suits that don't meet specified requirements
- Items must now meet set, spell, or stat constraints to be considered for suits
- Empty slots are now preferred over items that don't help with constraints

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 08:25:22 +00:00
erik
57a2384511 added inventory service for armor and jewelry 2025-06-12 23:05:33 +00:00
erik
09a6cd4946 added epic counters 2025-06-11 08:20:57 +00:00
erik
10c51f6825 added inventory, updated DB 2025-06-10 19:21:21 +00:00
erik
f218350959 Major overhaul of db -> hypertable conversion, updated GUI, added inventory 2025-06-08 20:51:06 +00:00
erik
fdf9f04bc6 added grafana and minor fix 2025-06-08 09:05:43 +00:00
erik
81ec59d220 caching queries to live and trails 2025-05-26 21:47:56 +00:00
erik
4de85b8db4 fixed CPU logging from db 2025-05-26 21:35:22 +00:00
erik
add24e5c9d Updated readme 2025-05-26 17:14:52 +00:00
erik
7845570819 Johan review 2025-05-25 22:12:08 +00:00
erik
d9b3b403da fixed crash and autorestart 2025-05-25 19:33:48 +00:00
erik
09404da121 new comments 2025-05-24 18:33:03 +00:00
erik
b2f649a489 New version with grafana 2025-05-23 08:11:11 +00:00
erik
f86ad9a542 fixed rares event 2025-05-22 16:29:05 +00:00
erik
c418221575 Working version with new streaming and DB 2025-05-22 15:30:45 +00:00
erik
c20d54d037 Refactor to async TimescaleDB backend & add Alembic migrations 2025-05-18 19:07:23 +00:00
erik
d396942deb Chat window is now movable 2025-05-18 11:14:43 +00:00
erik
0313c2a2ae Added docker file 2025-05-15 07:43:17 +00:00
erik
b94f064118 Alex ville ha färger på metastate för att han är en fisk 2025-05-14 09:21:19 +00:00
erik
337eff56aa added favicon 2025-05-09 23:31:01 +00:00
erik
73ae756e5c ws version with nice DB select 2025-05-09 22:35:41 +00:00
erik
a121d57a13 new Websockets from client only 2025-05-04 14:45:27 +00:00
16855 changed files with 167146 additions and 283 deletions

42
Dockerfile Normal file
View 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"]

View file

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

317
README.md
View file

@ -1,6 +1,6 @@
# Dereth Tracker # Dereth Tracker
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a 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
``` ```
# Grafana Dashboard UI
```nginx
location /grafana/ {
# Optional: require basic auth on the Grafana UI
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
- Live Map: `http://localhost:8000/` proxy_pass http://127.0.0.1:3000/;
- Analytics Dashboard: `http://localhost:8000/graphs.html` 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
View 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
View 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
View 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

View 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
View file

@ -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
View 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

View 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"]

View 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

View 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()

File diff suppressed because it is too large Load diff

View 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}")

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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