added portals, quest tracking, discord monitor etc etc

This commit is contained in:
erik 2025-06-23 19:26:44 +00:00
parent 72de9b0f7f
commit dffd295091
312 changed files with 4130 additions and 7 deletions

View file

@ -37,6 +37,7 @@ This project provides:
- 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
- **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

View file

@ -5,6 +5,7 @@ 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
@ -126,6 +127,20 @@ character_inventories = Table(
UniqueConstraint("character_name", "item_id", name="uq_char_item"),
)
# Portal discoveries table for 24-hour live tracking
portal_discoveries = Table(
# Records player portal discoveries with 24-hour retention
"portal_discoveries",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("portal_name", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("ns", Float, nullable=False), # North/South coordinate as float
Column("ew", Float, nullable=False), # East/West coordinate as float
Column("z", Float, nullable=False), # Elevation as float
)
# Server health monitoring tables
server_health_checks = Table(
# Time-series data for server health checks
@ -214,3 +229,21 @@ async def init_db_async():
))
except Exception as e:
print(f"Warning: failed to set retention/compression policies: {e}")
async def cleanup_old_portals():
"""Clean up portal discoveries older than 24 hours."""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
# Delete old portal discoveries
result = await database.execute(
"DELETE FROM portal_discoveries WHERE timestamp < :cutoff_time",
{"cutoff_time": cutoff_time}
)
print(f"Cleaned up {result} portal discoveries older than 24 hours")
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()

View file

@ -0,0 +1,880 @@
#!/usr/bin/env python3
"""
Discord Rare Monitor Bot - Monitors Dereth Tracker WebSocket for rare discoveries
and posts filtered notifications to Discord channels.
Listens for {"type": "rare"} messages from the WebSocket stream and posts them
to appropriate Discord channels based on rare classification.
"""
import asyncio
import json
import logging
import os
import re
import sys
import time
from datetime import datetime
from typing import Optional
import discord
import websockets
# Get log level from environment
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
# Configure logging
logging.basicConfig(
level=getattr(logging, log_level, logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
# Configuration from environment variables
DISCORD_TOKEN = os.getenv('DISCORD_RARE_BOT_TOKEN')
WEBSOCKET_URL = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/live')
SHARED_SECRET = 'your_shared_secret'
ACLOG_CHANNEL_ID = int(os.getenv('ACLOG_CHANNEL_ID', '1349649482786275328'))
COMMON_RARE_CHANNEL_ID = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014'))
GREAT_RARE_CHANNEL_ID = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211'))
# Character to monitor for allegiance chat
MONITOR_CHARACTER = os.getenv('MONITOR_CHARACTER', 'Dunking Rares')
# Comprehensive rare classification patterns
# Common Rares - Exact match pattern (70 items)
COMMON_RARES_PATTERN = re.compile(r"^(Alchemist's Crystal|Scholar's Crystal|Smithy's Crystal|Hunter's Crystal|Observer's Crystal|Thorsten's Crystal|Elysa's Crystal|Chef's Crystal|Enchanter's Crystal|Oswald's Crystal|Deceiver's Crystal|Fletcher's Crystal|Physician's Crystal|Artificer's Crystal|Tinker's Crystal|Vaulter's Crystal|Monarch's Crystal|Life Giver's Crystal|Thief's Crystal|Adherent's Crystal|Resister's Crystal|Imbuer's Crystal|Converter's Crystal|Evader's Crystal|Dodger's Crystal|Zefir's Crystal|Ben Ten's Crystal|Corruptor's Crystal|Artist's Crystal|T'ing's Crystal|Warrior's Crystal|Brawler's Crystal|Hieromancer's Crystal|Rogue's Crystal|Berzerker's Crystal|Lugian's Pearl|Ursuin's Pearl|Wayfarer's Pearl|Sprinter's Pearl|Magus's Pearl|Lich's Pearl|Warrior's Jewel|Melee's Jewel|Mage's Jewel|Duelist's Jewel|Archer's Jewel|Tusker's Jewel|Olthoi's Jewel|Inferno's Jewel|Gelid's Jewel|Astyrrian's Jewel|Executor's Jewel|Pearl of Blood Drinking|Pearl of Heart Seeking|Pearl of Defending|Pearl of Swift Killing|Pearl of Spirit Drinking|Pearl of Hermetic Linking|Pearl of Blade Baning|Pearl of Pierce Baning|Pearl of Bludgeon Baning|Pearl of Acid Baning|Pearl of Flame Baning|Pearl of Frost Baning|Pearl of Lightning Baning|Pearl of Impenetrability|Refreshing Elixir|Invigorating Elixir|Miraculous Elixir|Medicated Health Kit|Medicated Stamina Kit|Medicated Mana Kit|Casino Exquisite Keyring)$")
# Combined pattern for detecting any rare in chat messages (simplified for common detection)
RARE_IN_CHAT_PATTERN = re.compile(r"(Crystal|Pearl|Jewel|Elixir|Kit|Hieroglyph|Pictograph|Ideograph|Rune|Infinite|Eternal|Perennial|Foolproof|Limitless|Shimmering|Gelidite|Leikotha|Frore|Staff of|Count Renari|Wand of)")
# Legacy keywords for backward compatibility
COMMON_RARE_KEYWORDS = ["Crystal", "Jewel", "Pearl", "Elixir", "Kit"]
class DiscordRareMonitor:
"""Discord bot that monitors WebSocket for rare discoveries and posts to Discord."""
def __init__(self):
# Discord client setup
intents = discord.Intents.default()
intents.guilds = True
intents.messages = True
intents.message_content = True
self.client = discord.Client(intents=intents)
# WebSocket connection tracking
self.websocket_task: Optional[asyncio.Task] = None
self.running = False
# Setup Discord event handlers
self.setup_discord_handlers()
def setup_discord_handlers(self):
"""Setup Discord client event handlers."""
@self.client.event
async def on_ready():
logger.info(f'✅ Discord bot logged in as {self.client.user}')
# Debug: List all guilds (servers) the bot is in
guild_count = len(self.client.guilds)
logger.info(f"🏰 Bot is in {guild_count} guild(s)")
for guild in self.client.guilds:
logger.info(f" - {guild.name} (ID: {guild.id})")
# List first 5 channels in each guild for debugging
channels = list(guild.channels)[:5]
for channel in channels:
logger.info(f" Channel: #{channel.name} (ID: {channel.id})")
# Verify channels exist
aclog_channel = self.client.get_channel(ACLOG_CHANNEL_ID)
common_channel = self.client.get_channel(COMMON_RARE_CHANNEL_ID)
great_channel = self.client.get_channel(GREAT_RARE_CHANNEL_ID)
logger.info(f"🔍 Looking for aclog channel {ACLOG_CHANNEL_ID}: {'Found' if aclog_channel else 'NOT FOUND'}")
logger.info(f"🔍 Looking for common channel {COMMON_RARE_CHANNEL_ID}: {'Found' if common_channel else 'NOT FOUND'}")
logger.info(f"🔍 Looking for great channel {GREAT_RARE_CHANNEL_ID}: {'Found' if great_channel else 'NOT FOUND'}")
if aclog_channel:
logger.info(f"📍 AC Log channel: #{aclog_channel.name}")
if common_channel:
logger.info(f"📍 Common rares channel: #{common_channel.name}")
if great_channel:
logger.info(f"📍 Great rares channel: #{great_channel.name}")
logger.info("🎯 Bot ready to receive messages!")
# Start WebSocket monitoring
self.running = True
self.websocket_task = asyncio.create_task(self.monitor_websocket())
logger.info("🔄 Started WebSocket monitoring task")
@self.client.event
async def on_disconnect():
logger.warning("⚠️ Discord client disconnected")
self.running = False
if self.websocket_task:
self.websocket_task.cancel()
@self.client.event
async def on_message(message):
# Don't respond to bot's own messages
if message.author == self.client.user:
return
# Debug: log all messages (remove this after testing)
logger.info(f"📨 Received message: '{message.content}' from {message.author} in #{message.channel}")
# Handle !echo command for testing bot responsiveness
content = message.content # Don't convert to lowercase for echo
if content.startswith('!echo'):
# Extract the text after !echo
echo_text = message.content[5:].strip()
if echo_text:
await message.channel.send(f"🤖 Echo: {echo_text}")
else:
await message.channel.send("🤖 Echo: Hello! Discord Rare Monitor Bot is alive and responding!")
logger.info(f"📣 Responded to !echo command from {message.author} in #{message.channel}")
# Handle !status command for bot status
elif message.content.startswith('!status'):
status_msg = "🤖 **Discord Rare Monitor Bot Status:**\n"
status_msg += f"✅ Bot is online and connected\n"
status_msg += f"🔗 WebSocket: {'Connected' if self.websocket_task and not self.websocket_task.done() else 'Disconnected'}\n"
status_msg += f"📡 Monitoring: {WEBSOCKET_URL}\n"
status_msg += f"📍 Common Rares Channel: <#{COMMON_RARE_CHANNEL_ID}>\n"
status_msg += f"📍 Great Rares Channel: <#{GREAT_RARE_CHANNEL_ID}>"
await message.channel.send(status_msg)
logger.info(f"📊 Responded to !status command from {message.author} in #{message.channel}")
# Handle !icons command to display all rare icons
elif message.content.startswith('!icons'):
# Parse command arguments
args = message.content.split()
logger.info(f"🔍 !icons command received with args: {args}")
if len(args) > 1:
if args[1].lower() == 'all':
logger.info("📋 Calling handle_icons_command for 'all'")
await self.handle_icons_command(message)
elif args[1].lower() == 'grid':
await self.handle_icons_grid(message)
else:
# Search for specific rare item
search_term = ' '.join(args[1:])
await self.handle_icons_search(message, search_term)
else:
logger.info("📋 Calling handle_icons_summary (no args)")
await self.handle_icons_summary(message)
async def monitor_websocket(self):
"""Monitor Dereth Tracker WebSocket for rare events with robust reconnection."""
retry_delay = 5 # seconds
max_retry_delay = 300 # 5 minutes
consecutive_failures = 0
max_consecutive_failures = 10
while self.running:
websocket = None
last_message_time = time.time()
health_check_interval = 60 # Check health every 60 seconds
message_timeout = 180 # Consider connection dead if no messages for 3 minutes
try:
# Connect to live endpoint (no authentication needed for browsers)
logger.info(f"🔗 Connecting to WebSocket: {WEBSOCKET_URL}")
# Add connection timeout and ping interval for better connection health
websocket = await websockets.connect(
WEBSOCKET_URL,
ping_interval=30, # Send ping every 30 seconds
ping_timeout=10, # Wait 10 seconds for pong
close_timeout=10 # Wait 10 seconds for close
)
logger.info("✅ WebSocket connected successfully")
retry_delay = 5 # Reset retry delay on successful connection
consecutive_failures = 0 # Reset failure counter
last_message_time = time.time() # Reset message timer
# Send a test message to Discord to indicate connection restored
await self.post_status_to_aclog("🔗 WebSocket connection established")
# Create tasks for message processing and health checking
async def process_messages():
nonlocal last_message_time
async for message in websocket:
if not self.running:
break
last_message_time = time.time()
logger.debug(f"📨 Raw WebSocket message: {message[:100]}...")
await self.process_websocket_message(message)
async def health_check():
"""Periodically check connection health and force reconnect if needed."""
while self.running and websocket and not websocket.closed:
await asyncio.sleep(health_check_interval)
current_time = time.time()
time_since_last_message = current_time - last_message_time
if time_since_last_message > message_timeout:
logger.warning(f"⚠️ No messages received for {time_since_last_message:.0f} seconds, forcing reconnect")
await self.post_status_to_aclog(f"⚠️ WebSocket appears dead, reconnecting...")
if websocket and not websocket.closed:
await websocket.close()
break
else:
logger.debug(f"💓 WebSocket health check passed (last message {time_since_last_message:.0f}s ago)")
# Run both tasks concurrently
message_task = asyncio.create_task(process_messages())
health_task = asyncio.create_task(health_check())
# Wait for either task to complete (health check failure or message loop exit)
done, pending = await asyncio.wait(
[message_task, health_task],
return_when=asyncio.FIRST_COMPLETED
)
# Cancel any remaining tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"⚠️ WebSocket connection closed: {e}")
consecutive_failures += 1
await self.post_status_to_aclog(f"⚠️ WebSocket disconnected (attempt {consecutive_failures})")
except websockets.exceptions.InvalidStatusCode as e:
logger.error(f"❌ WebSocket invalid status code: {e}")
consecutive_failures += 1
await self.post_status_to_aclog(f"❌ WebSocket connection failed: {e}")
except Exception as e:
logger.error(f"❌ WebSocket error: {e}")
consecutive_failures += 1
await self.post_status_to_aclog(f"❌ WebSocket error: {e}")
finally:
# Ensure websocket is properly closed
if websocket and not websocket.closed:
try:
await websocket.close()
except:
pass
# Check if we should keep retrying
if consecutive_failures >= max_consecutive_failures:
logger.error(f"❌ Too many consecutive failures ({consecutive_failures}). Stopping reconnection attempts.")
await self.post_status_to_aclog(f"❌ WebSocket reconnection failed after {consecutive_failures} attempts. Bot may need restart.")
break
if self.running:
logger.info(f"🔄 Retrying WebSocket connection in {retry_delay} seconds... (failure #{consecutive_failures})")
await asyncio.sleep(retry_delay)
# Exponential backoff with max delay
retry_delay = min(retry_delay * 2, max_retry_delay)
async def process_websocket_message(self, raw_message: str):
"""Process incoming WebSocket message."""
try:
data = json.loads(raw_message)
msg_type = data.get('type')
# Debug: Log all message types
logger.debug(f"📩 WebSocket message type: {msg_type} from {data.get('character_name', 'Unknown')}")
# Handle rare event messages
if msg_type == 'rare':
await self.handle_rare_event(data)
# Handle chat messages from monitored character
elif msg_type == 'chat':
await self.handle_chat_message(data)
except json.JSONDecodeError:
# Ignore invalid JSON messages
pass
except Exception as e:
logger.error(f"❌ Error processing WebSocket message: {e}")
async def handle_rare_event(self, data: dict):
"""Handle a rare discovery event."""
try:
rare_name = data.get('name', 'Unknown Rare')
character_name = data.get('character_name', 'Unknown Character')
timestamp = data.get('timestamp', '')
logger.info(f"🎯 RARE EVENT RECEIVED: {rare_name} by {character_name}")
logger.debug(f"📦 Full rare data: {data}")
# Classify rare type
rare_type = self.classify_rare(rare_name)
# Post to Discord
await self.post_rare_to_discord(data, rare_type)
# DEBUG: Also post rare info to aclog for monitoring classification
await self.post_rare_debug_to_aclog(rare_name, rare_type, character_name)
logger.info(f"📬 Posted to Discord: {rare_name} ({rare_type}) from {character_name}")
except Exception as e:
logger.error(f"❌ Error handling rare event: {e}")
async def handle_chat_message(self, data: dict):
"""Handle a chat message from monitored character."""
try:
character_name = data.get('character_name', '')
chat_text = data.get('text', '')
logger.debug(f"🔍 Checking chat from '{character_name}' (looking for '{MONITOR_CHARACTER}')")
# Only process messages from the monitored character
if character_name != MONITOR_CHARACTER:
return
# Skip if this message contains any rare names (common or great)
if RARE_IN_CHAT_PATTERN.search(chat_text) or self.is_rare_message(chat_text):
logger.debug(f"🎯 Skipping rare message from {character_name}: {chat_text}")
return
logger.info(f"💬 Chat from {character_name}: {chat_text}")
# Post to AC Log channel
await self.post_chat_to_discord(data)
except Exception as e:
logger.error(f"❌ Error handling chat message: {e}")
def is_rare_message(self, text: str) -> bool:
"""Check if text appears to be a rare discovery message."""
# Look for common rare discovery patterns
rare_patterns = [
"has discovered the",
"found a rare",
"discovered a",
"rare discovery",
"Golden Gryphon",
"Dark Heart",
"Sunstone",
]
return any(pattern.lower() in text.lower() for pattern in rare_patterns)
def classify_rare(self, rare_name: str) -> str:
"""Classify rare as 'common' or 'great' based on exact name matching."""
# Use regex pattern matching for precise classification
if COMMON_RARES_PATTERN.match(rare_name):
return "common"
else:
return "great"
def get_rare_icon_path(self, rare_name: str) -> Optional[str]:
"""Get the file path for a rare item's icon if it exists."""
try:
# Convert rare name to icon filename using same logic as download script
filename = rare_name.replace("'", "").replace(" ", "_").replace("-", "_") + "_Icon.png"
# Get the icons directory path
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
icon_path = os.path.join(icons_dir, filename)
# Check if file exists
if os.path.exists(icon_path):
return icon_path
else:
logger.debug(f"🔍 Icon not found for '{rare_name}': {filename}")
return None
except Exception as e:
logger.warning(f"⚠️ Error getting icon path for '{rare_name}': {e}")
return None
async def post_rare_to_discord(self, data: dict, rare_type: str):
"""Post rare discovery to appropriate Discord channel."""
try:
rare_name = data.get('name', 'Unknown Rare')
character_name = data.get('character_name', 'Unknown Character')
timestamp_str = data.get('timestamp', '')
ew = data.get('ew')
ns = data.get('ns')
z = data.get('z')
# Parse timestamp
try:
if timestamp_str:
# Handle both with and without 'Z' suffix
if timestamp_str.endswith('Z'):
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
else:
timestamp = datetime.fromisoformat(timestamp_str)
else:
timestamp = datetime.now()
except ValueError:
timestamp = datetime.now()
# Try to get icon for this rare
icon_path = self.get_rare_icon_path(rare_name)
icon_file = None
icon_filename = None
if icon_path:
icon_filename = os.path.basename(icon_path)
icon_file = discord.File(icon_path, filename=icon_filename)
logger.debug(f"🖼️ Found icon for '{rare_name}': {icon_filename}")
else:
logger.debug(f"📷 No icon available for '{rare_name}'")
# Create Discord embed
if rare_type == "great":
embed = discord.Embed(
title="💎 Great Rare Discovery!",
description=f"**{character_name}** has discovered the **{rare_name}**!",
color=discord.Color.gold(),
timestamp=timestamp
)
else:
embed = discord.Embed(
title="🔸 Common Rare Discovery",
description=f"**{character_name}** has discovered the **{rare_name}**!",
color=discord.Color.blue(),
timestamp=timestamp
)
# Add icon image if available (at original size)
if icon_file and icon_filename:
embed.set_image(url=f"attachment://{icon_filename}")
# Add location if available
if ew is not None and ns is not None:
location_str = f"{ew:.1f}E, {ns:.1f}N"
if z is not None:
location_str += f", {z:.1f}Z"
embed.add_field(name="📍 Location", value=location_str, inline=True)
# Add timestamp in a readable format
embed.add_field(
name="⏰ Time",
value=timestamp.strftime("%H:%M:%S UTC"),
inline=True
)
# Get appropriate channel
if rare_type == "common":
channel = self.client.get_channel(COMMON_RARE_CHANNEL_ID)
else:
channel = self.client.get_channel(GREAT_RARE_CHANNEL_ID)
if channel:
# Send with or without icon file
if icon_file:
await channel.send(file=icon_file, embed=embed)
logger.debug(f"📤 Sent embed with icon to #{channel.name}")
else:
await channel.send(embed=embed)
logger.debug(f"📤 Sent text-only embed to #{channel.name}")
else:
logger.error(f"❌ Could not find Discord channel for {rare_type} rares")
except Exception as e:
logger.error(f"❌ Error posting to Discord: {e}")
async def post_chat_to_discord(self, data: dict):
"""Post chat message to AC Log Discord channel."""
try:
character_name = data.get('character_name', 'Unknown Character')
chat_text = data.get('text', '')
timestamp_str = data.get('timestamp', '')
# Parse timestamp
try:
if timestamp_str:
if timestamp_str.endswith('Z'):
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
else:
timestamp = datetime.fromisoformat(timestamp_str)
else:
timestamp = datetime.now()
except ValueError:
timestamp = datetime.now()
# Create simple message format similar to your old bot
time_str = timestamp.strftime("%H:%M:%S")
message_content = f"`{time_str}` **{character_name}**: {chat_text}"
# Get AC Log channel
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
if channel:
await channel.send(message_content)
logger.debug(f"📤 Posted chat to #{channel.name}: {character_name}")
else:
logger.error(f"❌ Could not find AC Log channel ID: {ACLOG_CHANNEL_ID}")
except Exception as e:
logger.error(f"❌ Error posting chat to Discord: {e}")
async def post_rare_debug_to_aclog(self, rare_name: str, rare_type: str, character_name: str):
"""Post rare classification debug info to AC Log channel."""
try:
# Create debug message showing classification
debug_message = f"🔍 **RARE DEBUG**: `{rare_name}` → **{rare_type.upper()}** (found by {character_name})"
# Get AC Log channel
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
if channel:
await channel.send(debug_message)
logger.debug(f"📤 Posted rare debug to #{channel.name}: {rare_name} -> {rare_type}")
else:
logger.error(f"❌ Could not find AC Log channel for debug: {ACLOG_CHANNEL_ID}")
except Exception as e:
logger.error(f"❌ Error posting rare debug to Discord: {e}")
async def post_status_to_aclog(self, status_message: str):
"""Post status update to AC Log channel."""
try:
# Create status message with timestamp
timestamp = datetime.now().strftime("%H:%M:%S")
message = f"`{timestamp}` **BOT STATUS**: {status_message}"
# Get AC Log channel
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
if channel:
await channel.send(message)
logger.debug(f"📤 Posted status to #{channel.name}: {status_message}")
else:
logger.error(f"❌ Could not find AC Log channel for status: {ACLOG_CHANNEL_ID}")
except Exception as e:
logger.error(f"❌ Error posting status to Discord: {e}")
async def handle_icons_command(self, message):
"""Handle !icons all command to display 10 random rare icons with images."""
try:
logger.info("🎯 STARTING handle_icons_command - will send 10 separate messages")
import random
import asyncio
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
# Get all icon files
all_icons = [f for f in os.listdir(icons_dir) if f.endswith("_Icon.png")]
logger.info(f"📂 Found {len(all_icons)} total icons")
# Select 10 random icons
random_icons = random.sample(all_icons, min(10, len(all_icons)))
logger.info(f"🎲 Selected {len(random_icons)} random icons to display")
# Send each icon individually - no embeds, just file + text
for i, filename in enumerate(random_icons):
# Convert filename to display name
base_name = filename[:-9]
display_name = base_name.replace("_", " ")
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")
# Classify rare
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
emoji = "🔸" if rare_type == "common" else "💎"
# Send just the file with simple text
icon_path = os.path.join(icons_dir, filename)
if os.path.exists(icon_path):
file = discord.File(icon_path, filename=filename)
text = f"{emoji} **{display_name}** ({rare_type} rare)"
logger.info(f"📤 Sending message {i+1}/10: {display_name}")
await message.channel.send(content=text, file=file)
# Small delay between messages to avoid rate limiting
await asyncio.sleep(0.8)
# Send summary message
await message.channel.send(f"📚 Displayed 10 random icons from {len(all_icons)} total rare icons. Use `!icons [name]` to search.")
logger.info(f"📚 Sent 10 random icon samples to {message.author} in #{message.channel}")
except Exception as e:
logger.error(f"❌ Error handling !icons command: {e}")
await message.channel.send(f"❌ Error displaying icons: {str(e)}")
async def handle_icons_summary(self, message):
"""Handle !icons command to display summary with example images."""
try:
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
# Count icons by category and get examples
common_count = 0
great_count = 0
icon_files = []
common_examples = []
great_examples = []
for filename in os.listdir(icons_dir):
if filename.endswith("_Icon.png"):
base_name = filename[:-9]
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")
icon_files.append((filename, display_name))
# Classify and collect examples
if self.classify_rare(display_name) == "common":
common_count += 1
if len(common_examples) < 3:
common_examples.append((filename, display_name))
else:
great_count += 1
if len(great_examples) < 3:
great_examples.append((filename, display_name))
total_count = len(icon_files)
# Create summary embed with thumbnail
embed = discord.Embed(
title="📚 Rare Icons Library Summary",
description=f"Complete collection of Asheron's Call rare item icons\n\n**{total_count}** total icons ({common_count} common, {great_count} great)",
color=discord.Color.gold()
)
# Add example icons text
examples_text = "**Common Rare Examples:**\n"
for _, name in common_examples:
examples_text += f"🔸 {name}\n"
examples_text += "\n**Great Rare Examples:**\n"
for _, name in great_examples:
examples_text += f"💎 {name}\n"
embed.add_field(
name="📋 Icon Examples",
value=examples_text,
inline=False
)
embed.add_field(
name="💡 Commands",
value="`!icons all` - Browse all icons with images\n`!icons [item_name]` - Search for specific item\n`!icons grid` - View icon grids",
inline=False
)
embed.set_footer(text=f"Coverage: {(total_count/293)*100:.1f}% of all known rares")
# Select a representative icon for thumbnail (first great rare)
if great_examples:
thumbnail_file = great_examples[0][0]
thumbnail_path = os.path.join(icons_dir, thumbnail_file)
if os.path.exists(thumbnail_path):
file = discord.File(thumbnail_path, filename=thumbnail_file)
embed.set_thumbnail(url=f"attachment://{thumbnail_file}")
await message.channel.send(file=file, embed=embed)
else:
await message.channel.send(embed=embed)
else:
await message.channel.send(embed=embed)
logger.info(f"📊 Sent icon summary with thumbnail to {message.author} in #{message.channel}")
except Exception as e:
logger.error(f"❌ Error handling !icons summary: {e}")
await message.channel.send(f"❌ Error displaying icon summary: {str(e)}")
async def handle_icons_search(self, message, search_term):
"""Handle !icons [item_name] to search for specific rare item."""
try:
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
# Load icon mapping
icon_mapping = {}
for filename in os.listdir(icons_dir):
if filename.endswith("_Icon.png"):
base_name = filename[:-9]
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")
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
# Search for matching items (case-insensitive, partial match)
search_lower = search_term.lower()
matches = []
for filename, display_name in icon_mapping.items():
if search_lower in display_name.lower():
matches.append((filename, display_name))
if not matches:
await message.channel.send(f"❌ No rare items found matching '{search_term}'. Try a different search term.")
return
# Sort matches by relevance (exact match first, then starts with, then contains)
def match_score(item):
name = item[1].lower()
if name == search_lower:
return 0 # Exact match
elif name.startswith(search_lower):
return 1 # Starts with
else:
return 2 # Contains
matches.sort(key=match_score)
# Limit to first 10 matches to avoid spam
matches = matches[:10]
if len(matches) == 1:
# Single match - show full detail with large image
filename, display_name = matches[0]
icon_path = os.path.join(icons_dir, filename)
if os.path.exists(icon_path):
# Get file stats
file_size = os.path.getsize(icon_path) / 1024
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
emoji = "🔸" if rare_type == "common" else "💎"
embed = discord.Embed(
title=f"{emoji} {display_name}",
description=f"**Type:** {rare_type.title()} Rare\n**File:** `{filename}`\n**Size:** {file_size:.1f} KB",
color=discord.Color.blue() if rare_type == "common" else discord.Color.gold()
)
file = discord.File(icon_path, filename=filename)
embed.set_image(url=f"attachment://{filename}")
await message.channel.send(file=file, embed=embed)
logger.info(f"🔍 Sent single icon result for '{search_term}' to {message.author}")
else:
await message.channel.send(f"❌ Icon file not found for {display_name}")
else:
# Multiple matches - show list with thumbnails
embed = discord.Embed(
title=f"🔍 Search Results for '{search_term}'",
description=f"Found {len(matches)} matching rare items:",
color=discord.Color.blue()
)
# Add matches to embed
for i, (filename, display_name) in enumerate(matches):
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
emoji = "🔸" if rare_type == "common" else "💎"
embed.add_field(
name=f"{emoji} {display_name}",
value=f"{rare_type.title()} rare",
inline=True
)
# Attach first 5 images
files = []
for i in range(min(5, len(matches))):
filename, _ = matches[i]
icon_path = os.path.join(icons_dir, filename)
if os.path.exists(icon_path):
files.append(discord.File(icon_path, filename=filename))
if files:
embed.add_field(
name="🖼️ Preview Images",
value=f"First {len(files)} results shown as attachments",
inline=False
)
await message.channel.send(files=files, embed=embed)
else:
await message.channel.send(embed=embed)
logger.info(f"🔍 Sent {len(matches)} search results for '{search_term}' to {message.author}")
except Exception as e:
logger.error(f"❌ Error handling !icons search: {e}")
await message.channel.send(f"❌ Error searching icons: {str(e)}")
async def handle_icons_grid(self, message):
"""Handle !icons grid to show icon grid compositions."""
await message.channel.send("🚧 **Grid View Coming Soon!**\n\nThis feature will show multiple icons arranged in grids. For now, use:\n• `!icons` - Summary with examples\n• `!icons all` - Browse all icons\n• `!icons [name]` - Search specific items")
async def start(self):
"""Start the Discord bot."""
if not DISCORD_TOKEN:
logger.error("❌ DISCORD_RARE_BOT_TOKEN environment variable not set")
return False
try:
logger.info("🚀 Starting Discord Rare Monitor Bot...")
await self.client.start(DISCORD_TOKEN)
except Exception as e:
logger.error(f"❌ Failed to start Discord bot: {e}")
return False
async def stop(self):
"""Stop the Discord bot and cleanup."""
logger.info("🛑 Stopping Discord Rare Monitor Bot...")
self.running = False
if self.websocket_task:
self.websocket_task.cancel()
try:
await self.websocket_task
except asyncio.CancelledError:
pass
if not self.client.is_closed():
await self.client.close()
async def main():
"""Main entry point."""
# Validate required environment variables
if not DISCORD_TOKEN:
logger.error("❌ Missing required environment variable: DISCORD_RARE_BOT_TOKEN")
sys.exit(1)
# Log configuration
actual_log_level = logging.getLevelName(logger.getEffectiveLevel())
logger.info("🔧 Discord Rare Monitor Configuration:")
logger.info(f" WebSocket URL: {WEBSOCKET_URL}")
logger.info(f" Monitor Character: {MONITOR_CHARACTER}")
logger.info(f" AC Log Channel ID: {ACLOG_CHANNEL_ID}")
logger.info(f" Common Rare Channel ID: {COMMON_RARE_CHANNEL_ID}")
logger.info(f" Great Rare Channel ID: {GREAT_RARE_CHANNEL_ID}")
logger.info(f" Log Level: {actual_log_level} (ENV: {log_level})")
# Create and start bot
bot = DiscordRareMonitor()
try:
await bot.start()
except KeyboardInterrupt:
logger.info("🛑 Received keyboard interrupt")
except Exception as e:
logger.error(f"❌ Unexpected error: {e}")
finally:
await bot.stop()
if __name__ == "__main__":
asyncio.run(main())

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

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.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

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