From 0e8186b8e52d4545cc45bcbd74e5dfb5815d0530 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 11 Mar 2026 20:02:52 +0100 Subject: [PATCH] feat: add mana tracker panel to inventory Derive equipped item mana state and time-remaining data in inventory-service, then render a Mana panel inside the inventory window with live icon, state, mana, and countdown display. --- AGENTS.md | 154 + inventory-service/main.py | 5814 ++++++++++++++++++++++--------------- static/script.js | 127 +- static/style.css | 118 +- 4 files changed, 3930 insertions(+), 2283 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..74f1e340 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md + +Guidance for coding agents working in `MosswartOverlord` (Dereth Tracker). + +Read shared integration rules first: `../AGENTS.md`. + +## Scope and priorities + +- This repo is a Python/FastAPI multi-service project with Docker-first workflows. +- Primary services: `main.py` (telemetry API + WS + static frontend), `inventory-service/main.py` (inventory + suitbuilder), `discord-rare-monitor/discord_rare_monitor.py` (Discord bot). +- Favor minimal, targeted changes over broad refactors. + +## Local rule sources + +- Additional project guidance exists in `CLAUDE.md`; follow it when relevant. +- Cursor/Copilot rule discovery is documented centrally in `../AGENTS.md`. + +## Environment and dependencies + +- Python versions in Dockerfiles: 3.12 (main + bot), 3.11 (inventory-service). +- Databases: PostgreSQL/TimescaleDB for telemetry; PostgreSQL for inventory. +- Core Python deps: FastAPI, Uvicorn, SQLAlchemy, databases, asyncpg, httpx. +- Bot deps: `discord.py`, `websockets`. + +## Build and run commands + +## Docker (recommended) + +- Start all services: `docker compose up -d` +- Rebuild app service after source changes (no cache): `docker compose build --no-cache dereth-tracker` +- Redeploy app service: `docker compose up -d dereth-tracker` +- Rebuild inventory service: `docker compose build --no-cache inventory-service` +- Rebuild Discord bot: `docker compose build --no-cache discord-rare-monitor` +- Follow logs (app): `docker logs mosswartoverlord-dereth-tracker-1` +- Follow logs (telemetry DB): `docker logs dereth-db` + +## Local (without Docker) + +- Main API dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8765` +- Inventory service dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8000` (from `inventory-service/`) +- Data generator: `python generate_data.py` +- Discord bot run: `python discord-rare-monitor/discord_rare_monitor.py` + +## Lint/format commands + +- Repo formatter target: `make reformat` +- What it does: runs `black *.py` in repo root. +- Prefer formatting changed files before finalizing edits. +- No repo-level Ruff/Flake8/isort/mypy config files were found. + +## Test commands + +- There is no conventional `tests/` suite configured in this repo. +- Existing executable test script: `python discord-rare-monitor/test_websocket.py` +- This script validates rare classification and WebSocket handling. +- It expects a reachable server at `ws://localhost:8765/ws/position` for connection checks. + +## Single-test guidance (important) + +- For the current codebase, a single targeted test means running the script above. +- Practical single-test command: +- `python discord-rare-monitor/test_websocket.py` +- The script is not pytest-based; use stdout/log output for pass/fail interpretation. +- If pytest is introduced later, preferred pattern is: +- `python -m pytest path/to/test_file.py::test_name -q` + +## Service-specific quick checks + +- Main health endpoint: `GET /debug` +- Live data endpoint: `GET /live` +- History endpoint: `GET /history` +- Plugin WS endpoint: `/ws/position` (authenticated) +- Browser WS endpoint: `/ws/live` (unauthenticated) +- Inventory service endpoint family: `/search/*`, `/inventory/*`, `/suitbuilder/*` + +## Repo-specific architecture notes + +- Telemetry DB schema is in `db_async.py` (SQLAlchemy Core tables). +- Inventory DB schema is in `inventory-service/database.py` (SQLAlchemy ORM models). +- Static frontend is served from `static/` by FastAPI. +- Keep inventory-service enum loading paths intact (`comprehensive_enum_database_v2.json`, fallback JSON). + +## Code style conventions observed + +## Imports and module structure + +- Use standard-library imports first, then third-party, then local imports. +- Keep import groups separated by one blank line. +- Prefer explicit imports over wildcard imports. +- In existing files, `typing` imports are common (`Dict`, `List`, `Optional`, `Any`). +- Avoid introducing circular imports; shared helpers belong in dedicated modules. + +## Formatting and layout + +- Follow Black-compatible formatting (88-char style assumptions are acceptable). +- Use 4 spaces, no tabs. +- Keep functions focused; extract helpers for repeated logic. +- Maintain existing docstring style (triple double quotes for module/function docs). +- Preserve readable logging statements with context-rich messages. + +## Types and data models + +- Add type hints for new functions and non-trivial variables. +- Use Pydantic models for request/response payload validation in FastAPI layers. +- Keep DB schema changes explicit in SQLAlchemy model/table definitions. +- Prefer precise types over `Any` when practical. +- For optional values, use `Optional[T]` or `T | None` consistently within a file. + +## Naming conventions + +- Functions/variables: `snake_case`. +- Classes: `PascalCase`. +- Constants/env names: `UPPER_SNAKE_CASE`. +- Endpoint handlers should be action-oriented and descriptive. +- Database table/column names should remain stable unless migration is planned. + +## Error handling and resilience + +- Prefer explicit `try/except` around external I/O boundaries: +- DB calls, WebSocket send/recv, HTTP calls, file I/O, JSON parsing. +- Log actionable errors with enough context to debug production issues. +- Fail gracefully for transient network/database errors (retry where already patterned). +- Do not swallow exceptions silently; at minimum log at `warning` or `error`. +- Keep user-facing APIs predictable (consistent JSON error responses). + +## Logging conventions + +- Use module-level logger: `logger = logging.getLogger(__name__)`. +- Respect `LOG_LEVEL` environment variable patterns already present. +- Prefer structured, concise messages; avoid noisy logs in hot loops. +- Keep emoji-heavy logging style only where already established in file context. + +## Database and migrations guidance + +- Be careful with uniqueness/index assumptions (especially portal coordinate rounding logic). +- Validate any schema-affecting changes against Dockerized Postgres services. + +## Frontend/static guidance + +- Preserve existing API base path assumptions used by frontend scripts. +- Reverse-proxy prefix behavior (`/api`) is documented in `../AGENTS.md`; keep frontend/backend paths aligned. + +## Secrets and configuration + +- Never hardcode secrets/tokens in commits. +- Use env vars (`SHARED_SECRET`, `POSTGRES_PASSWORD`, bot token variables). +- Keep defaults safe for local dev, not production credentials. + +## Change management for agents + +- Keep patches small and scoped to the requested task. +- Update docs when behavior, endpoints, or run commands change. +- If adding new tooling (pytest/ruff/mypy), include config and command docs in this file. +- For cross-repo payload changes, follow `../AGENTS.md` checklist and update both sides. diff --git a/inventory-service/main.py b/inventory-service/main.py index f1479c1f..24dc3a55 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -5,6 +5,7 @@ Handles enum translation, data normalization, and provides structured item data """ import json +import math import time import logging from pathlib import Path @@ -20,8 +21,16 @@ import databases import sqlalchemy as sa from database import ( - Base, Item, ItemCombatStats, ItemRequirements, ItemEnhancements, - ItemRatings, ItemSpells, ItemRawData, DATABASE_URL, create_indexes + Base, + Item, + ItemCombatStats, + ItemRequirements, + ItemEnhancements, + ItemRatings, + ItemSpells, + ItemRawData, + DATABASE_URL, + create_indexes, ) # Import helpers to share enum mappings @@ -34,6 +43,7 @@ logger = logging.getLogger(__name__) # Import suitbuilder router - after logger is defined try: from suitbuilder import router as suitbuilder_router, set_database_connection + SUITBUILDER_AVAILABLE = True logger.info("Suitbuilder module imported successfully") except ImportError as e: @@ -103,6 +113,7 @@ if SUITBUILDER_AVAILABLE: database = databases.Database(DATABASE_URL) engine = sa.create_engine(DATABASE_URL) + # Load comprehensive enum mappings def load_comprehensive_enums(): """Load complete enum database with all translations.""" @@ -110,151 +121,176 @@ def load_comprehensive_enums(): try: # Try new comprehensive database first logger.info("Attempting to load comprehensive_enum_database_v2.json") - with open('comprehensive_enum_database_v2.json', 'r') as f: + with open("comprehensive_enum_database_v2.json", "r") as f: enum_db = json.load(f) logger.info("Successfully loaded comprehensive_enum_database_v2.json") except FileNotFoundError: logger.warning("comprehensive_enum_database_v2.json not found, trying fallback") try: - with open('complete_enum_database.json', 'r') as f: + with open("complete_enum_database.json", "r") as f: enum_db = json.load(f) logger.info("Successfully loaded complete_enum_database.json") except FileNotFoundError as e: logger.error(f"No enum database found: {e}") - return {'int_values': {}, 'materials': {}, 'item_types': {}, 'skills': {}, 'spell_categories': {}, 'spells': {}, 'object_classes': {}, 'coverage_masks': {}} + return { + "int_values": {}, + "materials": {}, + "item_types": {}, + "skills": {}, + "spell_categories": {}, + "spells": {}, + "object_classes": {}, + "coverage_masks": {}, + } except Exception as e: logger.error(f"Error reading enum database file: {e}") - return {'int_values': {}, 'materials': {}, 'item_types': {}, 'skills': {}, 'spell_categories': {}, 'spells': {}, 'object_classes': {}, 'coverage_masks': {}} - + return { + "int_values": {}, + "materials": {}, + "item_types": {}, + "skills": {}, + "spell_categories": {}, + "spells": {}, + "object_classes": {}, + "coverage_masks": {}, + } + # Extract specific enum mappings for easy access from new format logger.info("Processing loaded enum database...") - enums = enum_db.get('enums', {}) - spells_data = enum_db.get('spells', {}) - object_classes_data = enum_db.get('object_classes', {}) - + enums = enum_db.get("enums", {}) + spells_data = enum_db.get("spells", {}) + object_classes_data = enum_db.get("object_classes", {}) + # Convert IntValueKey to integer-keyed dict for fast lookup int_values = {} - if 'IntValueKey' in enums: - for k, v in enums['IntValueKey']['values'].items(): + if "IntValueKey" in enums: + for k, v in enums["IntValueKey"]["values"].items(): try: int_values[int(k)] = v except (ValueError, TypeError): pass # Skip non-numeric keys - + # Material types mapping materials = {} - if 'MaterialType' in enums: - for k, v in enums['MaterialType']['values'].items(): + if "MaterialType" in enums: + for k, v in enums["MaterialType"]["values"].items(): try: materials[int(k)] = v except (ValueError, TypeError): pass - - # Item types mapping + + # Item types mapping item_types = {} - if 'ItemType' in enums: - for k, v in enums['ItemType']['values'].items(): + if "ItemType" in enums: + for k, v in enums["ItemType"]["values"].items(): try: item_types[int(k)] = v except (ValueError, TypeError): pass - + # Skills mapping skills = {} - if 'Skill' in enums: - skill_data = enums['Skill']['values'] + if "Skill" in enums: + skill_data = enums["Skill"]["values"] for k, v in skill_data.items(): try: if isinstance(v, dict): - skills[int(k)] = v.get('name', str(v)) + skills[int(k)] = v.get("name", str(v)) else: skills[int(k)] = str(v) except (ValueError, TypeError): pass - + # Spell categories mapping spell_categories = {} - if 'SpellCategory' in enums: - for k, v in enums['SpellCategory']['values'].items(): + if "SpellCategory" in enums: + for k, v in enums["SpellCategory"]["values"].items(): try: spell_categories[int(k)] = v except (ValueError, TypeError): pass - + # Coverage mask mapping coverage_masks = {} - if 'CoverageMask' in enums: - for k, v in enums['CoverageMask']['values'].items(): + if "CoverageMask" in enums: + for k, v in enums["CoverageMask"]["values"].items(): try: coverage_masks[int(k)] = v except (ValueError, TypeError): pass - + # Object classes mapping object_classes = {} - if object_classes_data and 'values' in object_classes_data: - for k, v in object_classes_data['values'].items(): + if object_classes_data and "values" in object_classes_data: + for k, v in object_classes_data["values"].items(): try: object_classes[int(k)] = v except (ValueError, TypeError): pass - + # Spells mapping spells = {} - if spells_data and 'values' in spells_data: - spells = {int(k): v for k, v in spells_data['values'].items() if k.isdigit()} - + if spells_data and "values" in spells_data: + spells = {int(k): v for k, v in spells_data["values"].items() if k.isdigit()} + # AttributeSetInfo - Equipment Set Names (CRITICAL for armor set detection) attribute_sets = {} # Check in dictionaries section first, then enums as fallback - if 'dictionaries' in enum_db and 'AttributeSetInfo' in enum_db['dictionaries']: - for k, v in enum_db['dictionaries']['AttributeSetInfo']['values'].items(): + if "dictionaries" in enum_db and "AttributeSetInfo" in enum_db["dictionaries"]: + for k, v in enum_db["dictionaries"]["AttributeSetInfo"]["values"].items(): attribute_sets[k] = v # String key try: attribute_sets[int(k)] = v # Also int key except (ValueError, TypeError): pass - elif 'AttributeSetInfo' in enums: - for k, v in enums['AttributeSetInfo']['values'].items(): + elif "AttributeSetInfo" in enums: + for k, v in enums["AttributeSetInfo"]["values"].items(): attribute_sets[k] = v # String key try: attribute_sets[int(k)] = v # Also int key except (ValueError, TypeError): pass - - logger.info(f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes, {len(attribute_sets)} equipment sets") - + + logger.info( + f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes, {len(attribute_sets)} equipment sets" + ) + return { - 'int_values': int_values, - 'materials': materials, - 'item_types': item_types, - 'skills': skills, - 'spell_categories': spell_categories, - 'coverage_masks': coverage_masks, - 'object_classes': object_classes, - 'spells': spells, - 'equipment_sets': attribute_sets, # Backward compatibility - 'dictionaries': { - 'AttributeSetInfo': {'values': attribute_sets} - }, - 'AttributeSetInfo': {'values': attribute_sets}, # Also add in the format expected by the endpoint - 'full_database': enum_db + "int_values": int_values, + "materials": materials, + "item_types": item_types, + "skills": skills, + "spell_categories": spell_categories, + "coverage_masks": coverage_masks, + "object_classes": object_classes, + "spells": spells, + "equipment_sets": attribute_sets, # Backward compatibility + "dictionaries": {"AttributeSetInfo": {"values": attribute_sets}}, + "AttributeSetInfo": { + "values": attribute_sets + }, # Also add in the format expected by the endpoint + "full_database": enum_db, } + ENUM_MAPPINGS = load_comprehensive_enums() # Share enum mappings with helpers module for suitbuilder helpers.set_enum_mappings(ENUM_MAPPINGS) + # Pydantic models class InventoryItem(BaseModel): """Raw inventory item from plugin.""" + character_name: str timestamp: datetime items: List[Dict[str, Any]] + class ProcessedItem(BaseModel): """Processed item with translated properties.""" + name: str icon: int object_class: int @@ -262,16 +298,18 @@ class ProcessedItem(BaseModel): burden: int # Add other fields as needed + # Response Models for API Documentation class ItemSearchResponse(BaseModel): """Response model for item search endpoint.""" + items: List[Dict[str, Any]] total_count: int page: int limit: int has_next: bool has_previous: bool - + class Config: schema_extra = { "example": { @@ -284,25 +322,27 @@ class ItemSearchResponse(BaseModel): "set_name": "Soldier's", "material_name": "Gold", "equipped": False, - "spells": ["Legendary Strength", "Legendary Endurance"] + "spells": ["Legendary Strength", "Legendary Endurance"], } ], "total_count": 1247, "page": 1, "limit": 200, "has_next": True, - "has_previous": False + "has_previous": False, } } + class ProcessingStats(BaseModel): """Response model for inventory processing results.""" + processed_count: int error_count: int character_name: str timestamp: datetime errors: Optional[List[str]] = None - + class Config: schema_extra = { "example": { @@ -310,83 +350,98 @@ class ProcessingStats(BaseModel): "error_count": 22, "character_name": "Megamula XXXIII", "timestamp": "2024-01-15T10:30:00Z", - "errors": ["Item ID 12345: SQL type error", "Item ID 67890: Missing required field"] + "errors": [ + "Item ID 12345: SQL type error", + "Item ID 67890: Missing required field", + ], } } + class SetListResponse(BaseModel): """Response model for equipment sets list.""" + sets: List[Dict[str, Any]] - + class Config: schema_extra = { "example": { "sets": [ {"set_name": "Soldier's", "item_count": 847, "set_id": 13}, {"set_name": "Adept's", "item_count": 623, "set_id": 14}, - {"set_name": "Hearty", "item_count": 1205, "set_id": 42} + {"set_name": "Hearty", "item_count": 1205, "set_id": 42}, ] } } + class HealthResponse(BaseModel): """Response model for health check.""" + status: str timestamp: datetime database_connected: bool version: str - + class Config: schema_extra = { "example": { "status": "healthy", "timestamp": "2024-01-15T10:30:00Z", "database_connected": True, - "version": "1.0.0" + "version": "1.0.0", } } + # Startup/shutdown events @app.on_event("startup") async def startup(): """Initialize database connection and create tables.""" await database.connect() - + # Share database connection with suitbuilder if SUITBUILDER_AVAILABLE and set_database_connection: set_database_connection(database) - + # Create tables if they don't exist Base.metadata.create_all(engine) # Migrate: add container_id and slot columns if missing (added for live inventory) from sqlalchemy import inspect as sa_inspect + inspector = sa_inspect(engine) - existing_columns = {c['name'] for c in inspector.get_columns('items')} + existing_columns = {c["name"] for c in inspector.get_columns("items")} with engine.begin() as conn: - if 'container_id' not in existing_columns: - conn.execute(sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0")) + if "container_id" not in existing_columns: + conn.execute( + sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0") + ) logger.info("Migration: added container_id column to items table") - if 'slot' not in existing_columns: - conn.execute(sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1")) + if "slot" not in existing_columns: + conn.execute( + sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1") + ) logger.info("Migration: added slot column to items table") # Create performance indexes create_indexes(engine) - + logger.info("Inventory service started successfully") + @app.on_event("shutdown") async def shutdown(): """Close database connection.""" await database.disconnect() + # Enhanced translation functions def translate_int_values(int_values: Dict[str, int]) -> Dict[str, Any]: """Translate IntValues enum keys to human-readable names using comprehensive database.""" translated = {} - int_enum_map = ENUM_MAPPINGS.get('int_values', {}) - + int_enum_map = ENUM_MAPPINGS.get("int_values", {}) + for key_str, value in int_values.items(): try: key_int = int(key_str) @@ -399,35 +454,41 @@ def translate_int_values(int_values: Dict[str, int]) -> Dict[str, Any]: except ValueError: # Skip non-numeric keys translated[key_str] = value - + return translated + def translate_material_type(material_id: int) -> str: """Translate material type ID to human-readable name.""" - materials = ENUM_MAPPINGS.get('materials', {}) + materials = ENUM_MAPPINGS.get("materials", {}) return materials.get(material_id, f"Unknown_Material_{material_id}") + def translate_item_type(item_type_id: int) -> str: """Translate item type ID to human-readable name.""" - item_types = ENUM_MAPPINGS.get('item_types', {}) + item_types = ENUM_MAPPINGS.get("item_types", {}) return item_types.get(item_type_id, f"Unknown_ItemType_{item_type_id}") -def derive_item_type_from_object_class(object_class: int, item_data: dict = None) -> str: + +def derive_item_type_from_object_class( + object_class: int, item_data: dict = None +) -> str: """Derive ItemType from ObjectClass using the object_classes enum.""" # Use the object_classes enum directly for accurate classifications - object_classes = ENUM_MAPPINGS.get('object_classes', {}) + object_classes = ENUM_MAPPINGS.get("object_classes", {}) item_type = object_classes.get(object_class) - + if item_type: return item_type else: # Fallback to "Misc" if ObjectClass not found in enum return "Misc" + def translate_equipment_set_id(set_id: str) -> str: """Translate equipment set ID to set name using comprehensive database.""" - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) + attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) set_name = attribute_set_info.get(str(set_id)) if set_name: return set_name @@ -435,122 +496,136 @@ def translate_equipment_set_id(set_id: str) -> str: # Fallback to just return the ID as string (matches database storage fallback) return str(set_id) + def translate_object_class(object_class_id: int, item_data: dict = None) -> str: """Translate object class ID to human-readable name with context-aware detection.""" # Use the extracted ObjectClass enum first - object_classes = ENUM_MAPPINGS.get('object_classes', {}) + object_classes = ENUM_MAPPINGS.get("object_classes", {}) if object_class_id in object_classes: base_name = object_classes[object_class_id] - + # Context-aware classification for Gem class (ID 11) if base_name == "Gem" and object_class_id == 11 and item_data: # Check item name and properties to distinguish types - item_name = item_data.get('Name', '').lower() - + item_name = item_data.get("Name", "").lower() + # Mana stones and crystals - if any(keyword in item_name for keyword in ['mana stone', 'crystal', 'gem']): - if 'mana stone' in item_name: + if any( + keyword in item_name for keyword in ["mana stone", "crystal", "gem"] + ): + if "mana stone" in item_name: return "Mana Stone" - elif 'crystal' in item_name: + elif "crystal" in item_name: return "Crystal" else: return "Gem" - + # Aetheria detection - check for specific properties - int_values = item_data.get('IntValues', {}) + int_values = item_data.get("IntValues", {}) if isinstance(int_values, dict): # Check for Aetheria-specific properties - has_item_set = '265' in int_values or 265 in int_values # EquipmentSetId - has_aetheria_level = '218103840' in int_values or 218103840 in int_values # ItemMaxLevel - - if has_item_set or has_aetheria_level or 'aetheria' in item_name: + has_item_set = ( + "265" in int_values or 265 in int_values + ) # EquipmentSetId + has_aetheria_level = ( + "218103840" in int_values or 218103840 in int_values + ) # ItemMaxLevel + + if has_item_set or has_aetheria_level or "aetheria" in item_name: return "Aetheria" - + # Default to Gem for other items in this class return "Gem" - + return base_name - + # Fallback to WeenieType enum weenie_types = {} - if 'WeenieType' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): - weenie_data = ENUM_MAPPINGS['full_database']['enums']['WeenieType']['values'] + if "WeenieType" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): + weenie_data = ENUM_MAPPINGS["full_database"]["enums"]["WeenieType"]["values"] for k, v in weenie_data.items(): try: weenie_types[int(k)] = v except (ValueError, TypeError): pass - + return weenie_types.get(object_class_id, f"Unknown_ObjectClass_{object_class_id}") + def translate_skill(skill_id: int) -> str: """Translate skill ID to skill name.""" - skills = ENUM_MAPPINGS.get('skills', {}) + skills = ENUM_MAPPINGS.get("skills", {}) return skills.get(skill_id, f"Unknown_Skill_{skill_id}") + def translate_spell_category(category_id: int) -> str: """Translate spell category ID to spell category name.""" - spell_categories = ENUM_MAPPINGS.get('spell_categories', {}) + spell_categories = ENUM_MAPPINGS.get("spell_categories", {}) return spell_categories.get(category_id, f"Unknown_SpellCategory_{category_id}") + def translate_spell(spell_id: int) -> Dict[str, Any]: """Translate spell ID to spell data including name, description, school, etc.""" - spells = ENUM_MAPPINGS.get('spells', {}) + spells = ENUM_MAPPINGS.get("spells", {}) spell_data = spells.get(spell_id) - + if spell_data: return { - 'id': spell_id, - 'name': spell_data.get('name', f'Unknown_Spell_{spell_id}'), - 'description': spell_data.get('description', ''), - 'school': spell_data.get('school', ''), - 'difficulty': spell_data.get('difficulty', ''), - 'duration': spell_data.get('duration', ''), - 'mana': spell_data.get('mana', ''), - 'family': spell_data.get('family', '') + "id": spell_id, + "name": spell_data.get("name", f"Unknown_Spell_{spell_id}"), + "description": spell_data.get("description", ""), + "school": spell_data.get("school", ""), + "difficulty": spell_data.get("difficulty", ""), + "duration": spell_data.get("duration", ""), + "mana": spell_data.get("mana", ""), + "family": spell_data.get("family", ""), } else: return { - 'id': spell_id, - 'name': f'Unknown_Spell_{spell_id}', - 'description': '', - 'school': '', - 'difficulty': '', - 'duration': '', - 'mana': '', - 'family': '' + "id": spell_id, + "name": f"Unknown_Spell_{spell_id}", + "description": "", + "school": "", + "difficulty": "", + "duration": "", + "mana": "", + "family": "", } + def translate_coverage_mask(coverage_value: int) -> List[str]: """Translate coverage mask value to list of body parts covered.""" - coverage_masks = ENUM_MAPPINGS.get('coverage_masks', {}) + coverage_masks = ENUM_MAPPINGS.get("coverage_masks", {}) covered_parts = [] - + # Coverage masks are flags, so we need to check each bit for mask_value, part_name in coverage_masks.items(): if coverage_value & mask_value: # Convert technical names to display names - display_name = part_name.replace('Outerwear', '').replace('Underwear', '').strip() + display_name = ( + part_name.replace("Outerwear", "").replace("Underwear", "").strip() + ) if display_name and display_name not in covered_parts: covered_parts.append(display_name) - + # Map technical names to user-friendly names name_mapping = { - 'UpperLegs': 'Upper Legs', - 'LowerLegs': 'Lower Legs', - 'UpperArms': 'Upper Arms', - 'LowerArms': 'Lower Arms', - 'Abdomen': 'Abdomen', - 'Chest': 'Chest', - 'Head': 'Head', - 'Hands': 'Hands', - 'Feet': 'Feet', - 'Cloak': 'Cloak', - 'Robe': 'Robe' + "UpperLegs": "Upper Legs", + "LowerLegs": "Lower Legs", + "UpperArms": "Upper Arms", + "LowerArms": "Lower Arms", + "Abdomen": "Abdomen", + "Chest": "Chest", + "Head": "Head", + "Hands": "Hands", + "Feet": "Feet", + "Cloak": "Cloak", + "Robe": "Robe", } - + return [name_mapping.get(part, part) for part in covered_parts if part] + def get_total_bits_set(value: int) -> int: """Count the number of bits set in a value.""" bits_set = 0 @@ -560,34 +635,39 @@ def get_total_bits_set(value: int) -> int: value >>= 1 return bits_set + def is_body_armor_equip_mask(value: int) -> bool: """Check if EquipMask value represents body armor.""" return (value & 0x00007F21) != 0 + def is_body_armor_coverage_mask(value: int) -> bool: """Check if CoverageMask value represents body armor.""" return (value & 0x0001FF00) != 0 + def get_coverage_reduction_options(coverage_value: int) -> list: """ Get the reduction options for a coverage mask, based on Mag-SuitBuilder logic. This determines which individual slots a multi-slot armor piece can be tailored to fit. """ # CoverageMask values from Mag-Plugins - OuterwearUpperArms = 4096 # 0x00001000 - OuterwearLowerArms = 8192 # 0x00002000 - OuterwearUpperLegs = 256 # 0x00000100 - OuterwearLowerLegs = 512 # 0x00000200 - OuterwearChest = 1024 # 0x00000400 - OuterwearAbdomen = 2048 # 0x00000800 - Head = 16384 # 0x00004000 - Hands = 32768 # 0x00008000 - Feet = 65536 # 0x00010000 - + OuterwearUpperArms = 4096 # 0x00001000 + OuterwearLowerArms = 8192 # 0x00002000 + OuterwearUpperLegs = 256 # 0x00000100 + OuterwearLowerLegs = 512 # 0x00000200 + OuterwearChest = 1024 # 0x00000400 + OuterwearAbdomen = 2048 # 0x00000800 + Head = 16384 # 0x00004000 + Hands = 32768 # 0x00008000 + Feet = 65536 # 0x00010000 + options = [] - + # If single bit or not body armor, return as-is - if get_total_bits_set(coverage_value) <= 1 or not is_body_armor_coverage_mask(coverage_value): + if get_total_bits_set(coverage_value) <= 1 or not is_body_armor_coverage_mask( + coverage_value + ): options.append(coverage_value) else: # Implement Mag-SuitBuilder reduction logic @@ -601,13 +681,19 @@ def get_coverage_reduction_options(coverage_value: int) -> list: options.append(OuterwearChest) elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms): options.append(OuterwearChest) - elif coverage_value == (OuterwearChest | OuterwearUpperArms | OuterwearLowerArms): + elif coverage_value == ( + OuterwearChest | OuterwearUpperArms | OuterwearLowerArms + ): options.append(OuterwearChest) elif coverage_value == (OuterwearChest | OuterwearUpperArms): options.append(OuterwearChest) - elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs): + elif coverage_value == ( + OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs + ): options.extend([OuterwearAbdomen, OuterwearUpperLegs, OuterwearLowerLegs]) - elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms): + elif coverage_value == ( + OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms + ): options.append(OuterwearChest) elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs): # Pre-2010 retail guidelines - assume abdomen reduction only @@ -615,26 +701,30 @@ def get_coverage_reduction_options(coverage_value: int) -> list: else: # If no specific reduction pattern, return original options.append(coverage_value) - + return options + def coverage_to_equip_mask(coverage_value: int) -> int: """Convert a CoverageMask value to its corresponding EquipMask slot.""" # Coverage to EquipMask mapping from Mag-SuitBuilder coverage_to_slot = { - 16384: 1, # Head -> HeadWear - 1024: 512, # OuterwearChest -> ChestArmor - 4096: 2048, # OuterwearUpperArms -> UpperArmArmor - 8192: 4096, # OuterwearLowerArms -> LowerArmArmor - 32768: 32, # Hands -> HandWear - 2048: 1024, # OuterwearAbdomen -> AbdomenArmor - 256: 8192, # OuterwearUpperLegs -> UpperLegArmor - 512: 16384, # OuterwearLowerLegs -> LowerLegArmor - 65536: 256, # Feet -> FootWear + 16384: 1, # Head -> HeadWear + 1024: 512, # OuterwearChest -> ChestArmor + 4096: 2048, # OuterwearUpperArms -> UpperArmArmor + 8192: 4096, # OuterwearLowerArms -> LowerArmArmor + 32768: 32, # Hands -> HandWear + 2048: 1024, # OuterwearAbdomen -> AbdomenArmor + 256: 8192, # OuterwearUpperLegs -> UpperLegArmor + 512: 16384, # OuterwearLowerLegs -> LowerLegArmor + 65536: 256, # Feet -> FootWear } return coverage_to_slot.get(coverage_value, coverage_value) -def get_sophisticated_slot_options(equippable_slots: int, coverage_value: int, has_material: bool = True) -> list: + +def get_sophisticated_slot_options( + equippable_slots: int, coverage_value: int, has_material: bool = True +) -> list: """ Get sophisticated slot options using Mag-SuitBuilder logic. This handles armor reduction for tailorable pieces. @@ -644,9 +734,12 @@ def get_sophisticated_slot_options(equippable_slots: int, coverage_value: int, h FootWear = 256 if equippable_slots == (LowerLegWear | FootWear): return [FootWear] - + # If it's body armor with multiple slots - if is_body_armor_equip_mask(equippable_slots) and get_total_bits_set(equippable_slots) > 1: + if ( + is_body_armor_equip_mask(equippable_slots) + and get_total_bits_set(equippable_slots) > 1 + ): if not has_material: # Can't reduce non-loot gen pieces, return all slots return [equippable_slots] @@ -662,165 +755,170 @@ def get_sophisticated_slot_options(equippable_slots: int, coverage_value: int, h # Single slot or non-armor return [equippable_slots] + def convert_slot_name_to_friendly(slot_name: str) -> str: """Convert technical slot names to user-friendly names.""" name_mapping = { - 'HeadWear': 'Head', - 'ChestWear': 'Chest', - 'ChestArmor': 'Chest', - 'AbdomenWear': 'Abdomen', - 'AbdomenArmor': 'Abdomen', - 'UpperArmWear': 'Upper Arms', - 'UpperArmArmor': 'Upper Arms', - 'LowerArmWear': 'Lower Arms', - 'LowerArmArmor': 'Lower Arms', - 'HandWear': 'Hands', - 'UpperLegWear': 'Upper Legs', - 'UpperLegArmor': 'Upper Legs', - 'LowerLegWear': 'Lower Legs', - 'LowerLegArmor': 'Lower Legs', - 'FootWear': 'Feet', - 'NeckWear': 'Neck', - 'WristWearLeft': 'Left Wrist', - 'WristWearRight': 'Right Wrist', - 'FingerWearLeft': 'Left Ring', - 'FingerWearRight': 'Right Ring', - 'MeleeWeapon': 'Melee Weapon', - 'Shield': 'Shield', - 'MissileWeapon': 'Missile Weapon', - 'MissileAmmo': 'Ammo', - 'Held': 'Held', - 'TwoHanded': 'Two-Handed', - 'TrinketOne': 'Trinket', - 'Cloak': 'Cloak', - 'Robe': 'Robe', - 'SigilOne': 'Aetheria Blue', - 'SigilTwo': 'Aetheria Yellow', - 'SigilThree': 'Aetheria Red' + "HeadWear": "Head", + "ChestWear": "Chest", + "ChestArmor": "Chest", + "AbdomenWear": "Abdomen", + "AbdomenArmor": "Abdomen", + "UpperArmWear": "Upper Arms", + "UpperArmArmor": "Upper Arms", + "LowerArmWear": "Lower Arms", + "LowerArmArmor": "Lower Arms", + "HandWear": "Hands", + "UpperLegWear": "Upper Legs", + "UpperLegArmor": "Upper Legs", + "LowerLegWear": "Lower Legs", + "LowerLegArmor": "Lower Legs", + "FootWear": "Feet", + "NeckWear": "Neck", + "WristWearLeft": "Left Wrist", + "WristWearRight": "Right Wrist", + "FingerWearLeft": "Left Ring", + "FingerWearRight": "Right Ring", + "MeleeWeapon": "Melee Weapon", + "Shield": "Shield", + "MissileWeapon": "Missile Weapon", + "MissileAmmo": "Ammo", + "Held": "Held", + "TwoHanded": "Two-Handed", + "TrinketOne": "Trinket", + "Cloak": "Cloak", + "Robe": "Robe", + "SigilOne": "Aetheria Blue", + "SigilTwo": "Aetheria Yellow", + "SigilThree": "Aetheria Red", } return name_mapping.get(slot_name, slot_name) + def translate_equipment_slot(wielded_location: int) -> str: """Translate equipment slot mask to human-readable slot name(s), handling bit flags.""" if wielded_location == 0: return "Inventory" - + # Get EquipMask enum from database equip_mask_map = {} - if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): - equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] + if "EquipMask" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): + equip_data = ENUM_MAPPINGS["full_database"]["enums"]["EquipMask"]["values"] for k, v in equip_data.items(): try: # Skip expression values (EXPR:...) - if not k.startswith('EXPR:'): + if not k.startswith("EXPR:"): equip_mask_map[int(k)] = v except (ValueError, TypeError): pass - + # Check for exact match first if wielded_location in equip_mask_map: slot_name = equip_mask_map[wielded_location] # Convert technical names to user-friendly names name_mapping = { - 'HeadWear': 'Head', - 'ChestWear': 'Chest', - 'ChestArmor': 'Chest', - 'AbdomenWear': 'Abdomen', - 'AbdomenArmor': 'Abdomen', - 'UpperArmWear': 'Upper Arms', - 'UpperArmArmor': 'Upper Arms', - 'LowerArmWear': 'Lower Arms', - 'LowerArmArmor': 'Lower Arms', - 'HandWear': 'Hands', - 'UpperLegWear': 'Upper Legs', - 'UpperLegArmor': 'Upper Legs', - 'LowerLegWear': 'Lower Legs', - 'LowerLegArmor': 'Lower Legs', - 'FootWear': 'Feet', - 'NeckWear': 'Neck', - 'WristWearLeft': 'Left Wrist', - 'WristWearRight': 'Right Wrist', - 'FingerWearLeft': 'Left Ring', - 'FingerWearRight': 'Right Ring', - 'MeleeWeapon': 'Melee Weapon', - 'Shield': 'Shield', - 'MissileWeapon': 'Missile Weapon', - 'MissileAmmo': 'Ammo', - 'Held': 'Held', - 'TwoHanded': 'Two-Handed', - 'TrinketOne': 'Trinket', - 'Cloak': 'Cloak', - 'Robe': 'Robe' + "HeadWear": "Head", + "ChestWear": "Chest", + "ChestArmor": "Chest", + "AbdomenWear": "Abdomen", + "AbdomenArmor": "Abdomen", + "UpperArmWear": "Upper Arms", + "UpperArmArmor": "Upper Arms", + "LowerArmWear": "Lower Arms", + "LowerArmArmor": "Lower Arms", + "HandWear": "Hands", + "UpperLegWear": "Upper Legs", + "UpperLegArmor": "Upper Legs", + "LowerLegWear": "Lower Legs", + "LowerLegArmor": "Lower Legs", + "FootWear": "Feet", + "NeckWear": "Neck", + "WristWearLeft": "Left Wrist", + "WristWearRight": "Right Wrist", + "FingerWearLeft": "Left Ring", + "FingerWearRight": "Right Ring", + "MeleeWeapon": "Melee Weapon", + "Shield": "Shield", + "MissileWeapon": "Missile Weapon", + "MissileAmmo": "Ammo", + "Held": "Held", + "TwoHanded": "Two-Handed", + "TrinketOne": "Trinket", + "Cloak": "Cloak", + "Robe": "Robe", } return name_mapping.get(slot_name, slot_name) - + # Handle common equipment slots that may be missing from enum database common_slots = { 30: "Shirt", # ChestWear + AbdomenWear + UpperArmWear + LowerArmWear for shirts 786432: "Left Ring, Right Ring", # 262144 + 524288 for rings that can go in either slot 262144: "Left Ring", - 524288: "Right Ring" + 524288: "Right Ring", } - + if wielded_location in common_slots: return common_slots[wielded_location] - + # If no exact match, decode bit flags slot_parts = [] for mask_value, slot_name in equip_mask_map.items(): if mask_value > 0 and (wielded_location & mask_value) == mask_value: slot_parts.append(slot_name) - + if slot_parts: # Convert technical names to user-friendly names name_mapping = { - 'HeadWear': 'Head', - 'ChestWear': 'Chest', - 'ChestArmor': 'Chest', - 'AbdomenWear': 'Abdomen', - 'AbdomenArmor': 'Abdomen', - 'UpperArmWear': 'Upper Arms', - 'UpperArmArmor': 'Upper Arms', - 'LowerArmWear': 'Lower Arms', - 'LowerArmArmor': 'Lower Arms', - 'HandWear': 'Hands', - 'UpperLegWear': 'Upper Legs', - 'UpperLegArmor': 'Upper Legs', - 'LowerLegWear': 'Lower Legs', - 'LowerLegArmor': 'Lower Legs', - 'FootWear': 'Feet', - 'NeckWear': 'Neck', - 'WristWearLeft': 'Left Wrist', - 'WristWearRight': 'Right Wrist', - 'FingerWearLeft': 'Left Ring', - 'FingerWearRight': 'Right Ring', - 'MeleeWeapon': 'Melee Weapon', - 'Shield': 'Shield', - 'MissileWeapon': 'Missile Weapon', - 'MissileAmmo': 'Ammo', - 'Held': 'Held', - 'TwoHanded': 'Two-Handed', - 'TrinketOne': 'Trinket', - 'Cloak': 'Cloak', - 'Robe': 'Robe' + "HeadWear": "Head", + "ChestWear": "Chest", + "ChestArmor": "Chest", + "AbdomenWear": "Abdomen", + "AbdomenArmor": "Abdomen", + "UpperArmWear": "Upper Arms", + "UpperArmArmor": "Upper Arms", + "LowerArmWear": "Lower Arms", + "LowerArmArmor": "Lower Arms", + "HandWear": "Hands", + "UpperLegWear": "Upper Legs", + "UpperLegArmor": "Upper Legs", + "LowerLegWear": "Lower Legs", + "LowerLegArmor": "Lower Legs", + "FootWear": "Feet", + "NeckWear": "Neck", + "WristWearLeft": "Left Wrist", + "WristWearRight": "Right Wrist", + "FingerWearLeft": "Left Ring", + "FingerWearRight": "Right Ring", + "MeleeWeapon": "Melee Weapon", + "Shield": "Shield", + "MissileWeapon": "Missile Weapon", + "MissileAmmo": "Ammo", + "Held": "Held", + "TwoHanded": "Two-Handed", + "TrinketOne": "Trinket", + "Cloak": "Cloak", + "Robe": "Robe", } - + translated_parts = [name_mapping.get(part, part) for part in slot_parts] - return ', '.join(translated_parts) - + return ", ".join(translated_parts) + # Handle special cases for high values (like Aetheria slots) - if wielded_location >= 268435456: # 2^28 and higher - likely Aetheria or special slots + if ( + wielded_location >= 268435456 + ): # 2^28 and higher - likely Aetheria or special slots if wielded_location == 268435456: return "Aetheria Blue" elif wielded_location == 536870912: - return "Aetheria Yellow" + return "Aetheria Yellow" elif wielded_location == 1073741824: return "Aetheria Red" else: return f"Special Slot ({wielded_location})" - + return "-" + def translate_workmanship(workmanship_value: int) -> str: """Translate workmanship value to descriptive text.""" if workmanship_value <= 0: @@ -844,11 +942,12 @@ def translate_workmanship(workmanship_value: int) -> str: else: return f"Quality ({workmanship_value})" + def format_damage_resistance(armor_level: int, damage_type: str) -> str: """Format damage resistance values with descriptive text.""" if armor_level <= 0: return "" - + # Rough categorization based on armor level if armor_level < 200: category = "Poor" @@ -862,461 +961,574 @@ def format_damage_resistance(armor_level: int, damage_type: str) -> str: category = "Excellent" else: category = "Superior" - + return f"{category} ({armor_level})" + def get_damage_range_and_type(item_data: Dict[str, Any]) -> Dict[str, Any]: """Calculate damage range and determine damage type for weapons.""" damage_info = {} - - int_values = item_data.get('IntValues', {}) - double_values = item_data.get('DoubleValues', {}) - + + int_values = item_data.get("IntValues", {}) + double_values = item_data.get("DoubleValues", {}) + # Max damage max_damage = None - if '218103842' in int_values: - max_damage = int_values['218103842'] + if "218103842" in int_values: + max_damage = int_values["218103842"] elif 218103842 in int_values: max_damage = int_values[218103842] - + # Variance for damage range calculation variance = None - if '167772171' in double_values: - variance = double_values['167772171'] + if "167772171" in double_values: + variance = double_values["167772171"] elif 167772171 in double_values: variance = double_values[167772171] - + # Damage bonus damage_bonus = None - if '167772174' in double_values: - damage_bonus = double_values['167772174'] + if "167772174" in double_values: + damage_bonus = double_values["167772174"] elif 167772174 in double_values: damage_bonus = double_values[167772174] - + if max_damage and variance: # Calculate min damage: max_damage * (2 - variance) / 2 min_damage = max_damage * (2 - variance) / 2 - damage_info['damage_range'] = f"{min_damage:.2f} - {max_damage}" - damage_info['min_damage'] = min_damage - damage_info['max_damage'] = max_damage + damage_info["damage_range"] = f"{min_damage:.2f} - {max_damage}" + damage_info["min_damage"] = min_damage + damage_info["max_damage"] = max_damage elif max_damage: - damage_info['damage_range'] = str(max_damage) - damage_info['max_damage'] = max_damage - + damage_info["damage_range"] = str(max_damage) + damage_info["max_damage"] = max_damage + # Determine damage type from item name or properties - item_name = item_data.get('Name', '').lower() - if 'flaming' in item_name or 'fire' in item_name: - damage_info['damage_type'] = 'Fire' - elif 'frost' in item_name or 'ice' in item_name: - damage_info['damage_type'] = 'Cold' - elif 'lightning' in item_name or 'electric' in item_name: - damage_info['damage_type'] = 'Electrical' - elif 'acid' in item_name: - damage_info['damage_type'] = 'Acid' + item_name = item_data.get("Name", "").lower() + if "flaming" in item_name or "fire" in item_name: + damage_info["damage_type"] = "Fire" + elif "frost" in item_name or "ice" in item_name: + damage_info["damage_type"] = "Cold" + elif "lightning" in item_name or "electric" in item_name: + damage_info["damage_type"] = "Electrical" + elif "acid" in item_name: + damage_info["damage_type"] = "Acid" else: # Check for elemental damage bonus elemental_bonus = None - if '204' in int_values: - elemental_bonus = int_values['204'] + if "204" in int_values: + elemental_bonus = int_values["204"] elif 204 in int_values: elemental_bonus = int_values[204] - + if elemental_bonus and elemental_bonus > 0: - damage_info['damage_type'] = 'Elemental' + damage_info["damage_type"] = "Elemental" else: - damage_info['damage_type'] = 'Physical' - + damage_info["damage_type"] = "Physical" + return damage_info + def get_weapon_speed(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get weapon speed information.""" speed_info = {} - - int_values = item_data.get('IntValues', {}) - + + int_values = item_data.get("IntValues", {}) + # Weapon speed (check multiple possible keys) speed_value = None - speed_keys = ['169', 169, 'WeapSpeed_Decal'] + speed_keys = ["169", 169, "WeapSpeed_Decal"] for key in speed_keys: if key in int_values: speed_value = int_values[key] break - + # Also check translated enum properties for speed translated_ints = translate_int_values(int_values) - if 'WeaponSpeed' in translated_ints: - speed_value = translated_ints['WeaponSpeed'] - elif 'WeapSpeed' in translated_ints: - speed_value = translated_ints['WeapSpeed'] - elif 'WeapSpeed_Decal' in translated_ints: - speed_value = translated_ints['WeapSpeed_Decal'] - + if "WeaponSpeed" in translated_ints: + speed_value = translated_ints["WeaponSpeed"] + elif "WeapSpeed" in translated_ints: + speed_value = translated_ints["WeapSpeed"] + elif "WeapSpeed_Decal" in translated_ints: + speed_value = translated_ints["WeapSpeed_Decal"] + if speed_value: - speed_info['speed_value'] = speed_value - + speed_info["speed_value"] = speed_value + # Convert to descriptive text if speed_value <= 20: - speed_info['speed_text'] = f"Very Fast ({speed_value})" + speed_info["speed_text"] = f"Very Fast ({speed_value})" elif speed_value <= 30: - speed_info['speed_text'] = f"Fast ({speed_value})" + speed_info["speed_text"] = f"Fast ({speed_value})" elif speed_value <= 40: - speed_info['speed_text'] = f"Average ({speed_value})" + speed_info["speed_text"] = f"Average ({speed_value})" elif speed_value <= 50: - speed_info['speed_text'] = f"Slow ({speed_value})" + speed_info["speed_text"] = f"Slow ({speed_value})" else: - speed_info['speed_text'] = f"Very Slow ({speed_value})" - + speed_info["speed_text"] = f"Very Slow ({speed_value})" + return speed_info + def get_mana_and_spellcraft(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get mana and spellcraft information for items.""" mana_info = {} - - int_values = item_data.get('IntValues', {}) - + + int_values = item_data.get("IntValues", {}) + # Get translated enum properties first translated_ints = translate_int_values(int_values) - + # Current mana - check translated properties first current_mana = None - if 'ItemCurMana' in translated_ints: - current_mana = translated_ints['ItemCurMana'] - elif '73' in int_values: - current_mana = int_values['73'] + if "ItemCurMana" in translated_ints: + current_mana = translated_ints["ItemCurMana"] + elif "73" in int_values: + current_mana = int_values["73"] elif 73 in int_values: current_mana = int_values[73] - + # Max mana - check translated properties first max_mana = None - if 'ItemMaxMana' in translated_ints: - max_mana = translated_ints['ItemMaxMana'] - elif '72' in int_values: - max_mana = int_values['72'] + if "ItemMaxMana" in translated_ints: + max_mana = translated_ints["ItemMaxMana"] + elif "72" in int_values: + max_mana = int_values["72"] elif 72 in int_values: max_mana = int_values[72] - - # Spellcraft - check translated properties first + + # Spellcraft - check translated properties first spellcraft = None - if 'ItemSpellcraft' in translated_ints: - spellcraft = translated_ints['ItemSpellcraft'] - elif '106' in int_values: - spellcraft = int_values['106'] + if "ItemSpellcraft" in translated_ints: + spellcraft = translated_ints["ItemSpellcraft"] + elif "106" in int_values: + spellcraft = int_values["106"] elif 106 in int_values: spellcraft = int_values[106] - + if current_mana is not None and max_mana is not None: - mana_info['mana_display'] = f"{current_mana} / {max_mana}" - mana_info['current_mana'] = current_mana - mana_info['max_mana'] = max_mana - + mana_info["mana_display"] = f"{current_mana} / {max_mana}" + mana_info["current_mana"] = current_mana + mana_info["max_mana"] = max_mana + if spellcraft: - mana_info['spellcraft'] = spellcraft - + mana_info["spellcraft"] = spellcraft + return mana_info + +def _format_mana_time_remaining(total_seconds: Optional[int]) -> Optional[str]: + """Format remaining mana time in a compact display form.""" + if total_seconds is None or total_seconds < 0: + return None + + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}h{minutes:02d}m" + + +def get_mana_tracker_info(item_data: Dict[str, Any]) -> Dict[str, Any]: + """Build derived mana-tracker fields for equipped inventory display.""" + mana_info = get_mana_and_spellcraft(item_data) + int_values = item_data.get("IntValues", {}) + double_values = item_data.get("DoubleValues", {}) + + current_mana = mana_info.get("current_mana") + max_mana = mana_info.get("max_mana") + has_id_data = bool(item_data.get("HasIdData", False)) + spell_ids = item_data.get("Spells", []) or [] + + mana_rate_of_change = None + if "5" in double_values: + mana_rate_of_change = double_values["5"] + elif 5 in double_values: + mana_rate_of_change = double_values[5] + + if not has_id_data: + mana_state = "unknown" + elif not spell_ids or max_mana is None or max_mana <= 0: + mana_state = "not_activatable" + elif current_mana is None: + mana_state = "unknown" + elif current_mana <= 0: + mana_state = "not_active" + else: + mana_state = "active" + + seconds_per_burn = None + if mana_rate_of_change is not None and mana_rate_of_change < 0: + try: + seconds_per_burn = int(math.ceil(-0.2 / float(mana_rate_of_change))) * 5 + if seconds_per_burn <= 0: + seconds_per_burn = None + except (ValueError, ZeroDivisionError, TypeError): + seconds_per_burn = None + + mana_time_remaining_seconds = None + if mana_state == "active" and current_mana is not None and seconds_per_burn: + mana_time_remaining_seconds = max(int(current_mana) * int(seconds_per_burn), 0) + + equipped_location = int_values.get("10", int_values.get(10, 0)) + + return { + "mana_state": mana_state, + "mana_rate_of_change": mana_rate_of_change, + "seconds_per_burn": seconds_per_burn, + "mana_time_remaining_seconds": mana_time_remaining_seconds, + "mana_time_remaining_display": _format_mana_time_remaining( + mana_time_remaining_seconds + ), + "mana_snapshot_utc": datetime.utcnow().isoformat(), + "is_mana_tracked": bool(equipped_location) + and ( + (max_mana is not None and max_mana > 0) + or (current_mana is not None and current_mana > 0) + or bool(spell_ids) + or bool(mana_info.get("spellcraft")) + ), + } + + def get_comprehensive_translations(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get comprehensive translations for all aspects of an item.""" translations = {} - + # Translate IntValues - int_values = item_data.get('IntValues', {}) + int_values = item_data.get("IntValues", {}) if int_values: - translations['int_properties'] = translate_int_values(int_values) - + translations["int_properties"] = translate_int_values(int_values) + # Translate material if present (check multiple locations) - material_id = item_data.get('MaterialType') + material_id = item_data.get("MaterialType") if material_id is None: # Check IntValues for material (key 131) - int_values = item_data.get('IntValues', {}) - if isinstance(int_values, dict) and '131' in int_values: - material_id = int_values['131'] + int_values = item_data.get("IntValues", {}) + if isinstance(int_values, dict) and "131" in int_values: + material_id = int_values["131"] elif isinstance(int_values, dict) and 131 in int_values: material_id = int_values[131] - + if material_id is not None and material_id != 0: - translations['material_name'] = translate_material_type(material_id) - translations['material_id'] = material_id - + translations["material_name"] = translate_material_type(material_id) + translations["material_id"] = material_id + # Translate item type if present (check ItemType field or IntValues[1]) - item_type_id = item_data.get('ItemType') + item_type_id = item_data.get("ItemType") if item_type_id is None: # Check IntValues for ItemType (key 1) - int_values = item_data.get('IntValues', {}) + int_values = item_data.get("IntValues", {}) if isinstance(int_values, dict): - item_type_id = int_values.get('1', int_values.get(1)) - + item_type_id = int_values.get("1", int_values.get(1)) + if item_type_id is not None: - translations['item_type_name'] = translate_item_type(item_type_id) + translations["item_type_name"] = translate_item_type(item_type_id) else: # Fallback: derive ItemType from ObjectClass - object_class = item_data.get('ObjectClass') + object_class = item_data.get("ObjectClass") if object_class is not None: - translations['item_type_name'] = derive_item_type_from_object_class(object_class, item_data) - + translations["item_type_name"] = derive_item_type_from_object_class( + object_class, item_data + ) + # Translate object class using WeenieType enum - object_class = item_data.get('ObjectClass') + object_class = item_data.get("ObjectClass") if object_class is not None: - translations['object_class_name'] = translate_object_class(object_class, item_data) - + translations["object_class_name"] = translate_object_class( + object_class, item_data + ) + return translations + def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: """Extract and categorize item properties from raw JSON.""" - + # Get raw values for comprehensive extraction - int_values = item_data.get('IntValues', {}) - double_values = item_data.get('DoubleValues', {}) - + int_values = item_data.get("IntValues", {}) + double_values = item_data.get("DoubleValues", {}) + # Start with processed fields (if available) properties = { - 'basic': { - 'name': item_data.get('Name', ''), - 'icon': item_data.get('Icon', 0), - 'object_class': item_data.get('ObjectClass', 0), - 'value': item_data.get('Value', 0), - 'burden': item_data.get('Burden', 0), - 'has_id_data': item_data.get('HasIdData', False), - + "basic": { + "name": item_data.get("Name", ""), + "icon": item_data.get("Icon", 0), + "object_class": item_data.get("ObjectClass", 0), + "value": item_data.get("Value", 0), + "burden": item_data.get("Burden", 0), + "has_id_data": item_data.get("HasIdData", False), # Equipment status - handle string keys properly - 'current_wielded_location': int(int_values.get('10', int_values.get(10, '0'))), - + "current_wielded_location": int( + int_values.get("10", int_values.get(10, "0")) + ), # Item state - 'bonded': int_values.get('33', int_values.get(33, 0)), - 'attuned': int_values.get('114', int_values.get(114, 0)), - 'unique': bool(int_values.get('279', int_values.get(279, 0))), - + "bonded": int_values.get("33", int_values.get(33, 0)), + "attuned": int_values.get("114", int_values.get(114, 0)), + "unique": bool(int_values.get("279", int_values.get(279, 0))), # Stack/Container properties - 'stack_size': int_values.get('12', int_values.get(12, 1)), - 'max_stack_size': int_values.get('11', int_values.get(11, 1)), - 'items_capacity': int_values.get('6', int_values.get(6, -1)), - 'containers_capacity': int_values.get('7', int_values.get(7, -1)), - + "stack_size": int_values.get("12", int_values.get(12, 1)), + "max_stack_size": int_values.get("11", int_values.get(11, 1)), + "items_capacity": int_values.get("6", int_values.get(6, -1)), + "containers_capacity": int_values.get("7", int_values.get(7, -1)), # Durability - 'structure': int_values.get('92', int_values.get(92, -1)), - 'max_structure': int_values.get('91', int_values.get(91, -1)), - + "structure": int_values.get("92", int_values.get(92, -1)), + "max_structure": int_values.get("91", int_values.get(91, -1)), # Special item flags - 'rare_id': int_values.get('17', int_values.get(17, -1)), - 'lifespan': int_values.get('267', int_values.get(267, -1)), - 'remaining_lifespan': int_values.get('268', int_values.get(268, -1)), + "rare_id": int_values.get("17", int_values.get(17, -1)), + "lifespan": int_values.get("267", int_values.get(267, -1)), + "remaining_lifespan": int_values.get("268", int_values.get(268, -1)), }, - 'combat': { - 'max_damage': item_data.get('MaxDamage', -1), - 'armor_level': item_data.get('ArmorLevel', -1), - 'damage_bonus': item_data.get('DamageBonus', -1.0), - 'attack_bonus': item_data.get('AttackBonus', -1.0), - + "combat": { + "max_damage": item_data.get("MaxDamage", -1), + "armor_level": item_data.get("ArmorLevel", -1), + "damage_bonus": item_data.get("DamageBonus", -1.0), + "attack_bonus": item_data.get("AttackBonus", -1.0), # Defense bonuses from raw values - 'melee_defense_bonus': double_values.get('29', double_values.get(29, -1.0)), - 'magic_defense_bonus': double_values.get('150', double_values.get(150, -1.0)), - 'missile_defense_bonus': double_values.get('149', double_values.get(149, -1.0)), - 'elemental_damage_vs_monsters': double_values.get('152', double_values.get(152, -1.0)), - 'mana_conversion_bonus': double_values.get('144', double_values.get(144, -1.0)), - + "melee_defense_bonus": double_values.get("29", double_values.get(29, -1.0)), + "magic_defense_bonus": double_values.get( + "150", double_values.get(150, -1.0) + ), + "missile_defense_bonus": double_values.get( + "149", double_values.get(149, -1.0) + ), + "elemental_damage_vs_monsters": double_values.get( + "152", double_values.get(152, -1.0) + ), + "mana_conversion_bonus": double_values.get( + "144", double_values.get(144, -1.0) + ), # Advanced damage properties - 'cleaving': int_values.get('292', int_values.get(292, -1)), - 'elemental_damage_bonus': int_values.get('204', int_values.get(204, -1)), - 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), - 'damage_over_time': int_values.get('318', int_values.get(318, -1)), - + "cleaving": int_values.get("292", int_values.get(292, -1)), + "elemental_damage_bonus": int_values.get("204", int_values.get(204, -1)), + "crit_damage_rating": int_values.get("314", int_values.get(314, -1)), + "damage_over_time": int_values.get("318", int_values.get(318, -1)), # Resistances - 'resist_magic': int_values.get('36', int_values.get(36, -1)), - 'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), - 'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), - 'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), - 'life_resist_rating': int_values.get('351', int_values.get(351, -1)), - 'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), - + "resist_magic": int_values.get("36", int_values.get(36, -1)), + "crit_resist_rating": int_values.get("315", int_values.get(315, -1)), + "crit_damage_resist_rating": int_values.get("316", int_values.get(316, -1)), + "dot_resist_rating": int_values.get("350", int_values.get(350, -1)), + "life_resist_rating": int_values.get("351", int_values.get(351, -1)), + "nether_resist_rating": int_values.get("331", int_values.get(331, -1)), # Healing/Recovery - 'heal_over_time': int_values.get('312', int_values.get(312, -1)), - 'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), - + "heal_over_time": int_values.get("312", int_values.get(312, -1)), + "healing_resist_rating": int_values.get("317", int_values.get(317, -1)), # PvP properties - 'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), - 'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), - 'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), - 'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), + "pk_damage_rating": int_values.get("381", int_values.get(381, -1)), + "pk_damage_resist_rating": int_values.get("382", int_values.get(382, -1)), + "gear_pk_damage_rating": int_values.get("383", int_values.get(383, -1)), + "gear_pk_damage_resist_rating": int_values.get( + "384", int_values.get(384, -1) + ), }, - 'requirements': { - 'wield_level': item_data.get('WieldLevel', -1), - 'skill_level': item_data.get('SkillLevel', -1), - 'lore_requirement': item_data.get('LoreRequirement', -1), - 'equip_skill': item_data.get('EquipSkill'), + "requirements": { + "wield_level": item_data.get("WieldLevel", -1), + "skill_level": item_data.get("SkillLevel", -1), + "lore_requirement": item_data.get("LoreRequirement", -1), + "equip_skill": item_data.get("EquipSkill"), }, - 'enhancements': { - 'material': None, # Will be set below with proper logic - 'imbue': item_data.get('Imbue'), - 'tinks': item_data.get('Tinks', -1), - 'workmanship': item_data.get('Workmanship', -1.0), - 'item_set': None, # Will be set below with translation - + "enhancements": { + "material": None, # Will be set below with proper logic + "imbue": item_data.get("Imbue"), + "tinks": item_data.get("Tinks", -1), + "workmanship": item_data.get("Workmanship", -1.0), + "item_set": None, # Will be set below with translation # Advanced tinkering - 'num_times_tinkered': int_values.get('171', int_values.get(171, -1)), - 'free_tinkers_bitfield': int_values.get('264', int_values.get(264, -1)), - 'num_items_in_material': int_values.get('170', int_values.get(170, -1)), - + "num_times_tinkered": int_values.get("171", int_values.get(171, -1)), + "free_tinkers_bitfield": int_values.get("264", int_values.get(264, -1)), + "num_items_in_material": int_values.get("170", int_values.get(170, -1)), # Additional imbue effects - 'imbue_attempts': int_values.get('205', int_values.get(205, -1)), - 'imbue_successes': int_values.get('206', int_values.get(206, -1)), - 'imbued_effect2': int_values.get('303', int_values.get(303, -1)), - 'imbued_effect3': int_values.get('304', int_values.get(304, -1)), - 'imbued_effect4': int_values.get('305', int_values.get(305, -1)), - 'imbued_effect5': int_values.get('306', int_values.get(306, -1)), - 'imbue_stacking_bits': int_values.get('311', int_values.get(311, -1)), - + "imbue_attempts": int_values.get("205", int_values.get(205, -1)), + "imbue_successes": int_values.get("206", int_values.get(206, -1)), + "imbued_effect2": int_values.get("303", int_values.get(303, -1)), + "imbued_effect3": int_values.get("304", int_values.get(304, -1)), + "imbued_effect4": int_values.get("305", int_values.get(305, -1)), + "imbued_effect5": int_values.get("306", int_values.get(306, -1)), + "imbue_stacking_bits": int_values.get("311", int_values.get(311, -1)), # Set information - 'equipment_set_extra': int_values.get('321', int_values.get(321, -1)), - + "equipment_set_extra": int_values.get("321", int_values.get(321, -1)), # Special properties - 'aetheria_bitfield': int_values.get('322', int_values.get(322, -1)), - 'heritage_specific_armor': int_values.get('324', int_values.get(324, -1)), - + "aetheria_bitfield": int_values.get("322", int_values.get(322, -1)), + "heritage_specific_armor": int_values.get("324", int_values.get(324, -1)), # Cooldowns - 'shared_cooldown': int_values.get('280', int_values.get(280, -1)), + "shared_cooldown": int_values.get("280", int_values.get(280, -1)), }, - 'ratings': { - 'damage_rating': int_values.get('307', int_values.get(307, -1)), # DamageRating - 'crit_rating': int_values.get('313', int_values.get(313, -1)), # CritRating - 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), # CritDamageRating - 'heal_boost_rating': int_values.get('323', int_values.get(323, -1)), # HealingBoostRating - + "ratings": { + "damage_rating": int_values.get( + "307", int_values.get(307, -1) + ), # DamageRating + "crit_rating": int_values.get("313", int_values.get(313, -1)), # CritRating + "crit_damage_rating": int_values.get( + "314", int_values.get(314, -1) + ), # CritDamageRating + "heal_boost_rating": int_values.get( + "323", int_values.get(323, -1) + ), # HealingBoostRating # Missing critical ratings - 'damage_resist_rating': int_values.get('308', int_values.get(308, -1)), # DamageResistRating - 'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), # CritResistRating - 'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), # CritDamageResistRating - 'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), # HealingResistRating - 'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), # NetherResistRating - 'vitality_rating': int_values.get('341', int_values.get(341, -1)), # VitalityRating - 'healing_rating': int_values.get('342', int_values.get(342, -1)), # LumAugHealingRating - 'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), # DotResistRating - 'life_resist_rating': int_values.get('351', int_values.get(351, -1)), # LifeResistRating - + "damage_resist_rating": int_values.get( + "308", int_values.get(308, -1) + ), # DamageResistRating + "crit_resist_rating": int_values.get( + "315", int_values.get(315, -1) + ), # CritResistRating + "crit_damage_resist_rating": int_values.get( + "316", int_values.get(316, -1) + ), # CritDamageResistRating + "healing_resist_rating": int_values.get( + "317", int_values.get(317, -1) + ), # HealingResistRating + "nether_resist_rating": int_values.get( + "331", int_values.get(331, -1) + ), # NetherResistRating + "vitality_rating": int_values.get( + "341", int_values.get(341, -1) + ), # VitalityRating + "healing_rating": int_values.get( + "342", int_values.get(342, -1) + ), # LumAugHealingRating + "dot_resist_rating": int_values.get( + "350", int_values.get(350, -1) + ), # DotResistRating + "life_resist_rating": int_values.get( + "351", int_values.get(351, -1) + ), # LifeResistRating # Specialized ratings - 'sneak_attack_rating': int_values.get('356', int_values.get(356, -1)), # SneakAttackRating - 'recklessness_rating': int_values.get('357', int_values.get(357, -1)), # RecklessnessRating - 'deception_rating': int_values.get('358', int_values.get(358, -1)), # DeceptionRating - + "sneak_attack_rating": int_values.get( + "356", int_values.get(356, -1) + ), # SneakAttackRating + "recklessness_rating": int_values.get( + "357", int_values.get(357, -1) + ), # RecklessnessRating + "deception_rating": int_values.get( + "358", int_values.get(358, -1) + ), # DeceptionRating # PvP ratings - 'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), # PKDamageRating - 'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), # PKDamageResistRating - 'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), # GearPKDamageRating - 'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), # GearPKDamageResistRating - + "pk_damage_rating": int_values.get( + "381", int_values.get(381, -1) + ), # PKDamageRating + "pk_damage_resist_rating": int_values.get( + "382", int_values.get(382, -1) + ), # PKDamageResistRating + "gear_pk_damage_rating": int_values.get( + "383", int_values.get(383, -1) + ), # GearPKDamageRating + "gear_pk_damage_resist_rating": int_values.get( + "384", int_values.get(384, -1) + ), # GearPKDamageResistRating # Additional ratings - 'weakness_rating': int_values.get('329', int_values.get(329, -1)), - 'nether_over_time': int_values.get('330', int_values.get(330, -1)), - + "weakness_rating": int_values.get("329", int_values.get(329, -1)), + "nether_over_time": int_values.get("330", int_values.get(330, -1)), # Gear totals - 'gear_damage': int_values.get('370', int_values.get(370, -1)), - 'gear_damage_resist': int_values.get('371', int_values.get(371, -1)), - 'gear_crit': int_values.get('372', int_values.get(372, -1)), - 'gear_crit_resist': int_values.get('373', int_values.get(373, -1)), - 'gear_crit_damage': int_values.get('374', int_values.get(374, -1)), - 'gear_crit_damage_resist': int_values.get('375', int_values.get(375, -1)), - 'gear_healing_boost': int_values.get('376', int_values.get(376, -1)), - 'gear_max_health': int_values.get('379', int_values.get(379, -1)), - 'gear_nether_resist': int_values.get('377', int_values.get(377, -1)), - 'gear_life_resist': int_values.get('378', int_values.get(378, -1)), - 'gear_overpower': int_values.get('388', int_values.get(388, -1)), - 'gear_overpower_resist': int_values.get('389', int_values.get(389, -1)), - } + "gear_damage": int_values.get("370", int_values.get(370, -1)), + "gear_damage_resist": int_values.get("371", int_values.get(371, -1)), + "gear_crit": int_values.get("372", int_values.get(372, -1)), + "gear_crit_resist": int_values.get("373", int_values.get(373, -1)), + "gear_crit_damage": int_values.get("374", int_values.get(374, -1)), + "gear_crit_damage_resist": int_values.get("375", int_values.get(375, -1)), + "gear_healing_boost": int_values.get("376", int_values.get(376, -1)), + "gear_max_health": int_values.get("379", int_values.get(379, -1)), + "gear_nether_resist": int_values.get("377", int_values.get(377, -1)), + "gear_life_resist": int_values.get("378", int_values.get(378, -1)), + "gear_overpower": int_values.get("388", int_values.get(388, -1)), + "gear_overpower_resist": int_values.get("389", int_values.get(389, -1)), + }, } - + # Handle material field properly - check if already translated or needs translation - material_field = item_data.get('Material') + material_field = item_data.get("Material") if material_field and isinstance(material_field, str): # Material is already a translated string (like "Gold", "Iron", "Brass") - properties['enhancements']['material'] = material_field + properties["enhancements"]["material"] = material_field else: # Material needs translation from IntValues[131] - material_id = int_values.get('131', int_values.get(131)) + material_id = int_values.get("131", int_values.get(131)) if material_id: material_name = translate_material_type(material_id) # Only store if translation succeeded (not "Unknown_Material_*") - if not material_name.startswith('Unknown_Material_'): - properties['enhancements']['material'] = material_name - + if not material_name.startswith("Unknown_Material_"): + properties["enhancements"]["material"] = material_name + # Translate item_set ID to name for database storage - item_set_id = int_values.get('265', int_values.get(265)) + item_set_id = int_values.get("265", int_values.get(265)) if item_set_id: - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) + attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) set_name = attribute_set_info.get(str(item_set_id)) if set_name: - properties['enhancements']['item_set'] = set_name + properties["enhancements"]["item_set"] = set_name else: # Fallback to just store the ID as string - properties['enhancements']['item_set'] = str(item_set_id) - + properties["enhancements"]["item_set"] = str(item_set_id) + # Get comprehensive translations translations = get_comprehensive_translations(item_data) if translations: - properties['translations'] = translations - + properties["translations"] = translations + # Translate raw enum values if available - int_values = item_data.get('IntValues', {}) + int_values = item_data.get("IntValues", {}) if int_values: translated_ints = translate_int_values(int_values) - properties['translated_ints'] = translated_ints - + properties["translated_ints"] = translated_ints + # Extract spell information - spells = item_data.get('Spells', []) - active_spells = item_data.get('ActiveSpells', []) - + spells = item_data.get("Spells", []) + active_spells = item_data.get("ActiveSpells", []) + # Translate spell IDs to spell data translated_spells = [] for spell_id in spells: translated_spells.append(translate_spell(spell_id)) - + translated_active_spells = [] for spell_id in active_spells: translated_active_spells.append(translate_spell(spell_id)) - + # Get spell-related properties from IntValues - int_values = item_data.get('IntValues', {}) + int_values = item_data.get("IntValues", {}) spell_info = { - 'spell_ids': spells, - 'active_spell_ids': active_spells, - 'spell_count': len(spells), - 'active_spell_count': len(active_spells), - 'spells': translated_spells, - 'active_spells': translated_active_spells + "spell_ids": spells, + "active_spell_ids": active_spells, + "spell_count": len(spells), + "active_spell_count": len(active_spells), + "spells": translated_spells, + "active_spells": translated_active_spells, } - + # Extract spell-related IntValues for key_str, value in int_values.items(): key_int = int(key_str) if key_str.isdigit() else None if key_int: # Check for spell-related properties if key_int == 94: # TargetType/SpellDID - spell_info['spell_target_type'] = value + spell_info["spell_target_type"] = value elif key_int == 106: # ItemSpellcraft - spell_info['item_spellcraft'] = value + spell_info["item_spellcraft"] = value elif key_int in [218103816, 218103838, 218103848]: # Spell decals - spell_info[f'spell_decal_{key_int}'] = value - - properties['spells'] = spell_info - + spell_info[f"spell_decal_{key_int}"] = value + + properties["spells"] = spell_info + # Add weapon-specific information damage_info = get_damage_range_and_type(item_data) if damage_info: - properties['weapon_damage'] = damage_info - + properties["weapon_damage"] = damage_info + speed_info = get_weapon_speed(item_data) if speed_info: - properties['weapon_speed'] = speed_info - + properties["weapon_speed"] = speed_info + mana_info = get_mana_and_spellcraft(item_data) if mana_info: - properties['mana_info'] = mana_info - + properties["mana_info"] = mana_info + return properties + # API endpoints -@app.post("/process-inventory", - response_model=ProcessingStats, - summary="Process Raw Inventory Data", - description=""" +@app.post( + "/process-inventory", + response_model=ProcessingStats, + summary="Process Raw Inventory Data", + description=""" **Process and normalize raw inventory data from game plugins.** This endpoint receives raw inventory JSON data from game plugins and processes it @@ -1342,225 +1554,318 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: Returns detailed error information for items that fail to process, including SQL type errors, missing fields, and validation failures. """, - tags=["Data Processing"]) + tags=["Data Processing"], +) async def process_inventory(inventory: InventoryItem): """Process raw inventory data and store in normalized database format.""" - + processed_count = 0 error_count = 0 processing_errors = [] - + async with database.transaction(): # First, delete all existing items for this character from all related tables # Get item IDs to delete item_ids_query = "SELECT id FROM items WHERE character_name = :character_name" - item_ids = await database.fetch_all(item_ids_query, {"character_name": inventory.character_name}) - + item_ids = await database.fetch_all( + item_ids_query, {"character_name": inventory.character_name} + ) + if item_ids: - db_ids = [row['id'] for row in item_ids] + db_ids = [row["id"] for row in item_ids] # Delete from all related tables first - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): + for table in ( + "item_raw_data", + "item_combat_stats", + "item_requirements", + "item_enhancements", + "item_ratings", + "item_spells", + ): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", - {"ids": db_ids} + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) - + # Finally delete from main items table await database.execute( "DELETE FROM items WHERE character_name = :character_name", - {"character_name": inventory.character_name} + {"character_name": inventory.character_name}, ) - + # Then insert the new complete inventory for item_data in inventory.items: try: # Extract properties properties = extract_item_properties(item_data) - + # Create core item record - item_id = item_data.get('Id') + item_id = item_data.get("Id") if item_id is None: - logger.warning(f"Skipping item without ID: {item_data.get('Name', 'Unknown')}") + logger.warning( + f"Skipping item without ID: {item_data.get('Name', 'Unknown')}" + ) error_count += 1 continue - + # Insert or update core item (handle timezone-aware timestamps) timestamp = inventory.timestamp if timestamp.tzinfo is not None: timestamp = timestamp.replace(tzinfo=None) - + # Simple INSERT since we cleared the table first - basic = properties['basic'] - + basic = properties["basic"] + # Debug logging for problematic items if item_id in [-2133380247, -2144880287, -2136150336]: logger.info(f"Debug item {item_id}: basic={basic}") - logger.info(f"Debug item {item_id}: name='{basic['name']}' type={type(basic['name'])}") - logger.info(f"Debug item {item_id}: current_wielded_location={basic['current_wielded_location']} type={type(basic['current_wielded_location'])}") - logger.info(f"Debug item {item_id}: enhancements={properties['enhancements']}") - - item_stmt = sa.insert(Item).values( - character_name=inventory.character_name, - item_id=item_id, - timestamp=timestamp, - name=basic['name'], - icon=basic['icon'], - object_class=basic['object_class'], - value=basic['value'], - burden=basic['burden'], - has_id_data=basic['has_id_data'], - last_id_time=item_data.get('LastIdTime', 0), + logger.info( + f"Debug item {item_id}: name='{basic['name']}' type={type(basic['name'])}" + ) + logger.info( + f"Debug item {item_id}: current_wielded_location={basic['current_wielded_location']} type={type(basic['current_wielded_location'])}" + ) + logger.info( + f"Debug item {item_id}: enhancements={properties['enhancements']}" + ) - # Equipment status - current_wielded_location=basic['current_wielded_location'], + item_stmt = ( + sa.insert(Item) + .values( + character_name=inventory.character_name, + item_id=item_id, + timestamp=timestamp, + name=basic["name"], + icon=basic["icon"], + object_class=basic["object_class"], + value=basic["value"], + burden=basic["burden"], + has_id_data=basic["has_id_data"], + last_id_time=item_data.get("LastIdTime", 0), + # Equipment status + current_wielded_location=basic["current_wielded_location"], + # Container/position tracking + container_id=item_data.get("ContainerId", 0), + slot=int( + item_data.get("IntValues", {}).get( + "231735296", + item_data.get("IntValues", {}).get(231735296, -1), + ) + ), # Decal Slot_Decal key + # Item state + bonded=basic["bonded"], + attuned=basic["attuned"], + unique=basic["unique"], + # Stack/Container properties + stack_size=basic["stack_size"], + max_stack_size=basic["max_stack_size"], + items_capacity=basic["items_capacity"] + if basic["items_capacity"] != -1 + else None, + containers_capacity=basic["containers_capacity"] + if basic["containers_capacity"] != -1 + else None, + # Durability + structure=basic["structure"] + if basic["structure"] != -1 + else None, + max_structure=basic["max_structure"] + if basic["max_structure"] != -1 + else None, + # Special item flags + rare_id=basic["rare_id"] if basic["rare_id"] != -1 else None, + lifespan=basic["lifespan"] if basic["lifespan"] != -1 else None, + remaining_lifespan=basic["remaining_lifespan"] + if basic["remaining_lifespan"] != -1 + else None, + ) + .returning(Item.id) + ) - # Container/position tracking - container_id=item_data.get('ContainerId', 0), - slot=int(item_data.get('IntValues', {}).get('231735296', item_data.get('IntValues', {}).get(231735296, -1))), # Decal Slot_Decal key - - # Item state - bonded=basic['bonded'], - attuned=basic['attuned'], - unique=basic['unique'], - - # Stack/Container properties - stack_size=basic['stack_size'], - max_stack_size=basic['max_stack_size'], - items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, - containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, - - # Durability - structure=basic['structure'] if basic['structure'] != -1 else None, - max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, - - # Special item flags - rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, - lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, - remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, - ).returning(Item.id) - result = await database.fetch_one(item_stmt) - db_item_id = result['id'] - + db_item_id = result["id"] + # Store combat stats if applicable - combat = properties['combat'] + combat = properties["combat"] if any(v != -1 and v != -1.0 for v in combat.values()): - combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( - item_id=db_item_id, - **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) + combat_stmt = ( + sa.dialects.postgresql.insert(ItemCombatStats) + .values( + item_id=db_item_id, + **{ + k: v if v != -1 and v != -1.0 else None + for k, v in combat.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v != -1 and v != -1.0 else None + for k, v in combat.items() + } + ), + ) ) await database.execute(combat_stmt) - + # Store requirements if applicable - requirements = properties['requirements'] - if any(v not in [-1, None, ''] for v in requirements.values()): - req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( - item_id=db_item_id, - **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) + requirements = properties["requirements"] + if any(v not in [-1, None, ""] for v in requirements.values()): + req_stmt = ( + sa.dialects.postgresql.insert(ItemRequirements) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, None, ""] else None + for k, v in requirements.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, None, ""] else None + for k, v in requirements.items() + } + ), + ) ) await database.execute(req_stmt) - + # Store enhancements - always create record to capture item_set data - enhancements = properties['enhancements'] - enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) + enhancements = properties["enhancements"] + enh_stmt = ( + sa.dialects.postgresql.insert(ItemEnhancements) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, -1.0, None, ""] else None + for k, v in enhancements.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, -1.0, None, ""] else None + for k, v in enhancements.items() + } + ), + ) ) await database.execute(enh_stmt) - + # Store ratings if applicable - ratings = properties['ratings'] + ratings = properties["ratings"] if any(v not in [-1, -1.0, None] for v in ratings.values()): - rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) + rat_stmt = ( + sa.dialects.postgresql.insert(ItemRatings) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, -1.0, None] else None + for k, v in ratings.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, -1.0, None] else None + for k, v in ratings.items() + } + ), + ) ) await database.execute(rat_stmt) - + # Store spell data if applicable - spells = item_data.get('Spells', []) - active_spells = item_data.get('ActiveSpells', []) + spells = item_data.get("Spells", []) + active_spells = item_data.get("ActiveSpells", []) all_spells = set(spells + active_spells) - + if all_spells: # First delete existing spells for this item await database.execute( "DELETE FROM item_spells WHERE item_id = :item_id", - {"item_id": db_item_id} + {"item_id": db_item_id}, ) - + # Insert all spells for this item for spell_id in all_spells: is_active = spell_id in active_spells - spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( - item_id=db_item_id, - spell_id=spell_id, - is_active=is_active - ).on_conflict_do_nothing() + spell_stmt = ( + sa.dialects.postgresql.insert(ItemSpells) + .values( + item_id=db_item_id, + spell_id=spell_id, + is_active=is_active, + ) + .on_conflict_do_nothing() + ) await database.execute(spell_stmt) - + # Store raw data for completeness - raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( - item_id=db_item_id, - int_values=item_data.get('IntValues', {}), - double_values=item_data.get('DoubleValues', {}), - string_values=item_data.get('StringValues', {}), - bool_values=item_data.get('BoolValues', {}), - original_json=item_data - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict( - int_values=item_data.get('IntValues', {}), - double_values=item_data.get('DoubleValues', {}), - string_values=item_data.get('StringValues', {}), - bool_values=item_data.get('BoolValues', {}), - original_json=item_data + raw_stmt = ( + sa.dialects.postgresql.insert(ItemRawData) + .values( + item_id=db_item_id, + int_values=item_data.get("IntValues", {}), + double_values=item_data.get("DoubleValues", {}), + string_values=item_data.get("StringValues", {}), + bool_values=item_data.get("BoolValues", {}), + original_json=item_data, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + int_values=item_data.get("IntValues", {}), + double_values=item_data.get("DoubleValues", {}), + string_values=item_data.get("StringValues", {}), + bool_values=item_data.get("BoolValues", {}), + original_json=item_data, + ), ) ) await database.execute(raw_stmt) - + processed_count += 1 - + except Exception as e: - error_msg = f"Error processing item {item_data.get('Id', 'unknown')}: {e}" + error_msg = ( + f"Error processing item {item_data.get('Id', 'unknown')}: {e}" + ) logger.error(error_msg) processing_errors.append(error_msg) error_count += 1 - - logger.info(f"Inventory processing complete for {inventory.character_name}: {processed_count} processed, {error_count} errors, {len(inventory.items)} total items received") - + + logger.info( + f"Inventory processing complete for {inventory.character_name}: {processed_count} processed, {error_count} errors, {len(inventory.items)} total items received" + ) + return ProcessingStats( processed_count=processed_count, error_count=error_count, character_name=inventory.character_name, timestamp=inventory.timestamp, - errors=processing_errors if processing_errors else None + errors=processing_errors if processing_errors else None, ) -@app.post("/inventory/{character_name}/item", - summary="Upsert a single inventory item", - tags=["Data Processing"]) +@app.post( + "/inventory/{character_name}/item", + summary="Upsert a single inventory item", + tags=["Data Processing"], +) async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): """Process and upsert a single item for a character's inventory.""" - item_game_id = item.get('Id') or item.get('id') + item_game_id = item.get("Id") or item.get("id") if item_game_id is None: - raise HTTPException(status_code=400, detail="Item must have an 'Id' or 'id' field") + raise HTTPException( + status_code=400, detail="Item must have an 'Id' or 'id' field" + ) processed_count = 0 error_count = 0 @@ -1569,154 +1874,223 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): # Delete existing item with this character_name + item_id from all related tables existing = await database.fetch_all( "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id} + {"character_name": character_name, "item_id": item_game_id}, ) if existing: - db_ids = [row['id'] for row in existing] - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): + db_ids = [row["id"] for row in existing] + for table in ( + "item_raw_data", + "item_combat_stats", + "item_requirements", + "item_enhancements", + "item_ratings", + "item_spells", + ): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", - {"ids": db_ids} + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) await database.execute( "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id} + {"character_name": character_name, "item_id": item_game_id}, ) # Process and insert the single item using the same logic as process_inventory try: properties = extract_item_properties(item) - basic = properties['basic'] + basic = properties["basic"] timestamp = datetime.utcnow() - item_stmt = sa.insert(Item).values( - character_name=character_name, - item_id=item_game_id, - timestamp=timestamp, - name=basic['name'], - icon=basic['icon'], - object_class=basic['object_class'], - value=basic['value'], - burden=basic['burden'], - has_id_data=basic['has_id_data'], - last_id_time=item.get('LastIdTime', 0), - - # Equipment status - current_wielded_location=basic['current_wielded_location'], - - # Container/position tracking - container_id=item.get('ContainerId', 0), - slot=int(item.get('IntValues', {}).get('231735296', item.get('IntValues', {}).get(231735296, -1))), - - # Item state - bonded=basic['bonded'], - attuned=basic['attuned'], - unique=basic['unique'], - - # Stack/Container properties - stack_size=basic['stack_size'], - max_stack_size=basic['max_stack_size'], - items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, - containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, - - # Durability - structure=basic['structure'] if basic['structure'] != -1 else None, - max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, - - # Special item flags - rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, - lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, - remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, - ).returning(Item.id) + item_stmt = ( + sa.insert(Item) + .values( + character_name=character_name, + item_id=item_game_id, + timestamp=timestamp, + name=basic["name"], + icon=basic["icon"], + object_class=basic["object_class"], + value=basic["value"], + burden=basic["burden"], + has_id_data=basic["has_id_data"], + last_id_time=item.get("LastIdTime", 0), + # Equipment status + current_wielded_location=basic["current_wielded_location"], + # Container/position tracking + container_id=item.get("ContainerId", 0), + slot=int( + item.get("IntValues", {}).get( + "231735296", item.get("IntValues", {}).get(231735296, -1) + ) + ), + # Item state + bonded=basic["bonded"], + attuned=basic["attuned"], + unique=basic["unique"], + # Stack/Container properties + stack_size=basic["stack_size"], + max_stack_size=basic["max_stack_size"], + items_capacity=basic["items_capacity"] + if basic["items_capacity"] != -1 + else None, + containers_capacity=basic["containers_capacity"] + if basic["containers_capacity"] != -1 + else None, + # Durability + structure=basic["structure"] if basic["structure"] != -1 else None, + max_structure=basic["max_structure"] + if basic["max_structure"] != -1 + else None, + # Special item flags + rare_id=basic["rare_id"] if basic["rare_id"] != -1 else None, + lifespan=basic["lifespan"] if basic["lifespan"] != -1 else None, + remaining_lifespan=basic["remaining_lifespan"] + if basic["remaining_lifespan"] != -1 + else None, + ) + .returning(Item.id) + ) result = await database.fetch_one(item_stmt) - db_item_id = result['id'] + db_item_id = result["id"] # Store combat stats if applicable - combat = properties['combat'] + combat = properties["combat"] if any(v != -1 and v != -1.0 for v in combat.values()): - combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( - item_id=db_item_id, - **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) + combat_stmt = ( + sa.dialects.postgresql.insert(ItemCombatStats) + .values( + item_id=db_item_id, + **{ + k: v if v != -1 and v != -1.0 else None + for k, v in combat.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v != -1 and v != -1.0 else None + for k, v in combat.items() + } + ), + ) ) await database.execute(combat_stmt) # Store requirements if applicable - requirements = properties['requirements'] - if any(v not in [-1, None, ''] for v in requirements.values()): - req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( - item_id=db_item_id, - **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) + requirements = properties["requirements"] + if any(v not in [-1, None, ""] for v in requirements.values()): + req_stmt = ( + sa.dialects.postgresql.insert(ItemRequirements) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, None, ""] else None + for k, v in requirements.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, None, ""] else None + for k, v in requirements.items() + } + ), + ) ) await database.execute(req_stmt) # Store enhancements - enhancements = properties['enhancements'] - enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) + enhancements = properties["enhancements"] + enh_stmt = ( + sa.dialects.postgresql.insert(ItemEnhancements) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, -1.0, None, ""] else None + for k, v in enhancements.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, -1.0, None, ""] else None + for k, v in enhancements.items() + } + ), + ) ) await database.execute(enh_stmt) # Store ratings if applicable - ratings = properties['ratings'] + ratings = properties["ratings"] if any(v not in [-1, -1.0, None] for v in ratings.values()): - rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) + rat_stmt = ( + sa.dialects.postgresql.insert(ItemRatings) + .values( + item_id=db_item_id, + **{ + k: v if v not in [-1, -1.0, None] else None + for k, v in ratings.items() + }, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + **{ + k: v if v not in [-1, -1.0, None] else None + for k, v in ratings.items() + } + ), + ) ) await database.execute(rat_stmt) # Store spell data if applicable - spells = item.get('Spells', []) - active_spells = item.get('ActiveSpells', []) + spells = item.get("Spells", []) + active_spells = item.get("ActiveSpells", []) all_spells = set(spells + active_spells) if all_spells: await database.execute( "DELETE FROM item_spells WHERE item_id = :item_id", - {"item_id": db_item_id} + {"item_id": db_item_id}, ) for spell_id in all_spells: is_active = spell_id in active_spells - spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( - item_id=db_item_id, - spell_id=spell_id, - is_active=is_active - ).on_conflict_do_nothing() + spell_stmt = ( + sa.dialects.postgresql.insert(ItemSpells) + .values( + item_id=db_item_id, spell_id=spell_id, is_active=is_active + ) + .on_conflict_do_nothing() + ) await database.execute(spell_stmt) # Store raw data - raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( - item_id=db_item_id, - int_values=item.get('IntValues', {}), - double_values=item.get('DoubleValues', {}), - string_values=item.get('StringValues', {}), - bool_values=item.get('BoolValues', {}), - original_json=item - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict( - int_values=item.get('IntValues', {}), - double_values=item.get('DoubleValues', {}), - string_values=item.get('StringValues', {}), - bool_values=item.get('BoolValues', {}), - original_json=item + raw_stmt = ( + sa.dialects.postgresql.insert(ItemRawData) + .values( + item_id=db_item_id, + int_values=item.get("IntValues", {}), + double_values=item.get("DoubleValues", {}), + string_values=item.get("StringValues", {}), + bool_values=item.get("BoolValues", {}), + original_json=item, + ) + .on_conflict_do_update( + index_elements=["item_id"], + set_=dict( + int_values=item.get("IntValues", {}), + double_values=item.get("DoubleValues", {}), + string_values=item.get("StringValues", {}), + bool_values=item.get("BoolValues", {}), + original_json=item, + ), ) ) await database.execute(raw_stmt) @@ -1729,7 +2103,9 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): error_count = 1 raise HTTPException(status_code=500, detail=error_msg) - logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}") + logger.info( + f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}" + ) # Fetch the just-upserted item with all joins and enrich it enriched_item = None @@ -1761,9 +2137,11 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): return {"status": "ok", "processed": processed_count, "item": enriched_item} -@app.delete("/inventory/{character_name}/item/{item_id}", - summary="Delete a single inventory item", - tags=["Data Processing"]) +@app.delete( + "/inventory/{character_name}/item/{item_id}", + summary="Delete a single inventory item", + tags=["Data Processing"], +) async def delete_inventory_item(character_name: str, item_id: int): """Delete a single item from a character's inventory.""" @@ -1773,45 +2151,53 @@ async def delete_inventory_item(character_name: str, item_id: int): # Find all DB rows for this character + game item_id existing = await database.fetch_all( "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id} + {"character_name": character_name, "item_id": item_id}, ) if existing: - db_ids = [row['id'] for row in existing] + db_ids = [row["id"] for row in existing] # Delete from all related tables first - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): + for table in ( + "item_raw_data", + "item_combat_stats", + "item_requirements", + "item_enhancements", + "item_ratings", + "item_spells", + ): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", - {"ids": db_ids} + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) # Delete from main items table await database.execute( "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id} + {"character_name": character_name, "item_id": item_id}, ) deleted_count = len(existing) - logger.info(f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}") + logger.info( + f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}" + ) return {"status": "ok", "deleted": deleted_count} def enrich_db_item(item) -> dict: """Enrich a single DB row (from the JOIN query) into the full frontend format. - + Takes a mapping (e.g. asyncpg Record or dict) from the items+joins query and returns a clean dict with translated material names, spell info, combat stats, ratings, workmanship text, mana display, etc. Identical logic to what get_character_inventory used inline — extracted here so upsert_inventory_item can reuse it. """ processed_item = dict(item) + original_json = {} # Get comprehensive translations from original_json - if processed_item.get('original_json'): - original_json = processed_item['original_json'] + if processed_item.get("original_json"): + original_json = processed_item["original_json"] # Handle case where original_json might be stored as string if isinstance(original_json, str): try: @@ -1824,210 +2210,264 @@ def enrich_db_item(item) -> dict: properties = extract_item_properties(original_json) # Add translated properties to the item - processed_item['translated_properties'] = properties + processed_item["translated_properties"] = properties # Add material name - use material directly if it's already a string - if processed_item.get('material'): - if isinstance(processed_item['material'], str): - processed_item['material_name'] = processed_item['material'] + if processed_item.get("material"): + if isinstance(processed_item["material"], str): + processed_item["material_name"] = processed_item["material"] else: - processed_item['material_name'] = translate_material_type(processed_item['material']) + processed_item["material_name"] = translate_material_type( + processed_item["material"] + ) # Add object class translation - if processed_item.get('object_class'): - processed_item['object_class_name'] = translate_object_class(processed_item['object_class'], original_json) + if processed_item.get("object_class"): + processed_item["object_class_name"] = translate_object_class( + processed_item["object_class"], original_json + ) # Add skill translation - if processed_item.get('equip_skill'): - processed_item['equip_skill_name'] = translate_skill(processed_item['equip_skill']) + if processed_item.get("equip_skill"): + processed_item["equip_skill_name"] = translate_skill( + processed_item["equip_skill"] + ) # Flatten the structure - move translated properties to top level - if 'translated_properties' in processed_item: - translated_props = processed_item.pop('translated_properties') + if "translated_properties" in processed_item: + translated_props = processed_item.pop("translated_properties") # Move spells to top level - if 'spells' in translated_props: - processed_item['spells'] = translated_props['spells'] + if "spells" in translated_props: + processed_item["spells"] = translated_props["spells"] # Move translated_ints to top level for enhanced tooltips - if 'translated_ints' in translated_props: - processed_item['enhanced_properties'] = translated_props['translated_ints'] + if "translated_ints" in translated_props: + processed_item["enhanced_properties"] = translated_props["translated_ints"] # Add weapon damage information - if 'weapon_damage' in translated_props: - weapon_damage = translated_props['weapon_damage'] - if weapon_damage.get('damage_range'): - processed_item['damage_range'] = weapon_damage['damage_range'] - if weapon_damage.get('damage_type'): - processed_item['damage_type'] = weapon_damage['damage_type'] - if weapon_damage.get('min_damage'): - processed_item['min_damage'] = weapon_damage['min_damage'] + if "weapon_damage" in translated_props: + weapon_damage = translated_props["weapon_damage"] + if weapon_damage.get("damage_range"): + processed_item["damage_range"] = weapon_damage["damage_range"] + if weapon_damage.get("damage_type"): + processed_item["damage_type"] = weapon_damage["damage_type"] + if weapon_damage.get("min_damage"): + processed_item["min_damage"] = weapon_damage["min_damage"] # Add weapon speed information - if 'weapon_speed' in translated_props: - speed_info = translated_props['weapon_speed'] - if speed_info.get('speed_text'): - processed_item['speed_text'] = speed_info['speed_text'] - if speed_info.get('speed_value'): - processed_item['speed_value'] = speed_info['speed_value'] + if "weapon_speed" in translated_props: + speed_info = translated_props["weapon_speed"] + if speed_info.get("speed_text"): + processed_item["speed_text"] = speed_info["speed_text"] + if speed_info.get("speed_value"): + processed_item["speed_value"] = speed_info["speed_value"] # Add mana and spellcraft information - if 'mana_info' in translated_props: - mana_info = translated_props['mana_info'] - if mana_info.get('mana_display'): - processed_item['mana_display'] = mana_info['mana_display'] - if mana_info.get('spellcraft'): - processed_item['spellcraft'] = mana_info['spellcraft'] - if mana_info.get('current_mana'): - processed_item['current_mana'] = mana_info['current_mana'] - if mana_info.get('max_mana'): - processed_item['max_mana'] = mana_info['max_mana'] + if "mana_info" in translated_props: + mana_info = translated_props["mana_info"] + if mana_info.get("mana_display"): + processed_item["mana_display"] = mana_info["mana_display"] + if mana_info.get("spellcraft"): + processed_item["spellcraft"] = mana_info["spellcraft"] + if mana_info.get("current_mana") is not None: + processed_item["current_mana"] = mana_info["current_mana"] + if mana_info.get("max_mana") is not None: + processed_item["max_mana"] = mana_info["max_mana"] + + mana_tracker = get_mana_tracker_info(original_json) + if mana_tracker.get("mana_state"): + processed_item["mana_state"] = mana_tracker["mana_state"] + if mana_tracker.get("mana_rate_of_change") is not None: + processed_item["mana_rate_of_change"] = mana_tracker[ + "mana_rate_of_change" + ] + if mana_tracker.get("seconds_per_burn") is not None: + processed_item["seconds_per_burn"] = mana_tracker["seconds_per_burn"] + if mana_tracker.get("mana_time_remaining_seconds") is not None: + processed_item["mana_time_remaining_seconds"] = mana_tracker[ + "mana_time_remaining_seconds" + ] + if mana_tracker.get("mana_time_remaining_display"): + processed_item["mana_time_remaining_display"] = mana_tracker[ + "mana_time_remaining_display" + ] + if mana_tracker.get("mana_snapshot_utc"): + processed_item["mana_snapshot_utc"] = mana_tracker["mana_snapshot_utc"] + if mana_tracker.get("is_mana_tracked") is not None: + processed_item["is_mana_tracked"] = mana_tracker["is_mana_tracked"] # Add icon overlay/underlay information from translated properties - if 'translated_ints' in translated_props: - translated_ints = translated_props['translated_ints'] + if "translated_ints" in translated_props: + translated_ints = translated_props["translated_ints"] # Icon overlay - check for the proper property name - if 'IconOverlay_Decal_DID' in translated_ints and translated_ints['IconOverlay_Decal_DID'] > 0: - processed_item['icon_overlay_id'] = translated_ints['IconOverlay_Decal_DID'] + if ( + "IconOverlay_Decal_DID" in translated_ints + and translated_ints["IconOverlay_Decal_DID"] > 0 + ): + processed_item["icon_overlay_id"] = translated_ints[ + "IconOverlay_Decal_DID" + ] # Icon underlay - check for the proper property name - if 'IconUnderlay_Decal_DID' in translated_ints and translated_ints['IconUnderlay_Decal_DID'] > 0: - processed_item['icon_underlay_id'] = translated_ints['IconUnderlay_Decal_DID'] + if ( + "IconUnderlay_Decal_DID" in translated_ints + and translated_ints["IconUnderlay_Decal_DID"] > 0 + ): + processed_item["icon_underlay_id"] = translated_ints[ + "IconUnderlay_Decal_DID" + ] # Add comprehensive combat stats - if 'combat' in translated_props: - combat_stats = translated_props['combat'] - if combat_stats.get('max_damage', -1) != -1: - processed_item['max_damage'] = combat_stats['max_damage'] - if combat_stats.get('armor_level', -1) != -1: - processed_item['armor_level'] = combat_stats['armor_level'] - if combat_stats.get('damage_bonus', -1.0) != -1.0: - processed_item['damage_bonus'] = combat_stats['damage_bonus'] - if combat_stats.get('attack_bonus', -1.0) != -1.0: - processed_item['attack_bonus'] = combat_stats['attack_bonus'] + if "combat" in translated_props: + combat_stats = translated_props["combat"] + if combat_stats.get("max_damage", -1) != -1: + processed_item["max_damage"] = combat_stats["max_damage"] + if combat_stats.get("armor_level", -1) != -1: + processed_item["armor_level"] = combat_stats["armor_level"] + if combat_stats.get("damage_bonus", -1.0) != -1.0: + processed_item["damage_bonus"] = combat_stats["damage_bonus"] + if combat_stats.get("attack_bonus", -1.0) != -1.0: + processed_item["attack_bonus"] = combat_stats["attack_bonus"] # Add missing combat bonuses - if combat_stats.get('melee_defense_bonus', -1.0) != -1.0: - processed_item['melee_defense_bonus'] = combat_stats['melee_defense_bonus'] - if combat_stats.get('magic_defense_bonus', -1.0) != -1.0: - processed_item['magic_defense_bonus'] = combat_stats['magic_defense_bonus'] - if combat_stats.get('missile_defense_bonus', -1.0) != -1.0: - processed_item['missile_defense_bonus'] = combat_stats['missile_defense_bonus'] - if combat_stats.get('elemental_damage_vs_monsters', -1.0) != -1.0: - processed_item['elemental_damage_vs_monsters'] = combat_stats['elemental_damage_vs_monsters'] - if combat_stats.get('mana_conversion_bonus', -1.0) != -1.0: - processed_item['mana_conversion_bonus'] = combat_stats['mana_conversion_bonus'] + if combat_stats.get("melee_defense_bonus", -1.0) != -1.0: + processed_item["melee_defense_bonus"] = combat_stats[ + "melee_defense_bonus" + ] + if combat_stats.get("magic_defense_bonus", -1.0) != -1.0: + processed_item["magic_defense_bonus"] = combat_stats[ + "magic_defense_bonus" + ] + if combat_stats.get("missile_defense_bonus", -1.0) != -1.0: + processed_item["missile_defense_bonus"] = combat_stats[ + "missile_defense_bonus" + ] + if combat_stats.get("elemental_damage_vs_monsters", -1.0) != -1.0: + processed_item["elemental_damage_vs_monsters"] = combat_stats[ + "elemental_damage_vs_monsters" + ] + if combat_stats.get("mana_conversion_bonus", -1.0) != -1.0: + processed_item["mana_conversion_bonus"] = combat_stats[ + "mana_conversion_bonus" + ] # Add comprehensive requirements - if 'requirements' in translated_props: - requirements = translated_props['requirements'] - if requirements.get('wield_level', -1) != -1: - processed_item['wield_level'] = requirements['wield_level'] - if requirements.get('skill_level', -1) != -1: - processed_item['skill_level'] = requirements['skill_level'] - if requirements.get('lore_requirement', -1) != -1: - processed_item['lore_requirement'] = requirements['lore_requirement'] - if requirements.get('equip_skill'): - processed_item['equip_skill'] = requirements['equip_skill'] + if "requirements" in translated_props: + requirements = translated_props["requirements"] + if requirements.get("wield_level", -1) != -1: + processed_item["wield_level"] = requirements["wield_level"] + if requirements.get("skill_level", -1) != -1: + processed_item["skill_level"] = requirements["skill_level"] + if requirements.get("lore_requirement", -1) != -1: + processed_item["lore_requirement"] = requirements["lore_requirement"] + if requirements.get("equip_skill"): + processed_item["equip_skill"] = requirements["equip_skill"] # Add comprehensive enhancements - if 'enhancements' in translated_props: - enhancements = translated_props['enhancements'] - if enhancements.get('material'): - processed_item['material'] = enhancements['material'] + if "enhancements" in translated_props: + enhancements = translated_props["enhancements"] + if enhancements.get("material"): + processed_item["material"] = enhancements["material"] # Add material information from translations - if 'translations' in translated_props: - trans = translated_props['translations'] - if trans.get('material_name'): - processed_item['material_name'] = trans['material_name'] - if trans.get('material_id'): - processed_item['material_id'] = trans['material_id'] - if trans.get('item_type_name'): - processed_item['item_type_name'] = trans['item_type_name'] + if "translations" in translated_props: + trans = translated_props["translations"] + if trans.get("material_name"): + processed_item["material_name"] = trans["material_name"] + if trans.get("material_id"): + processed_item["material_id"] = trans["material_id"] + if trans.get("item_type_name"): + processed_item["item_type_name"] = trans["item_type_name"] # Continue with other enhancements - if 'enhancements' in translated_props: - enhancements = translated_props['enhancements'] - if enhancements.get('imbue'): - processed_item['imbue'] = enhancements['imbue'] - if enhancements.get('tinks', -1) != -1: - processed_item['tinks'] = enhancements['tinks'] - if enhancements.get('workmanship', -1.0) != -1.0: - processed_item['workmanship'] = enhancements['workmanship'] - processed_item['workmanship_text'] = translate_workmanship(int(enhancements['workmanship'])) - if enhancements.get('item_set'): - processed_item['item_set'] = enhancements['item_set'] + if "enhancements" in translated_props: + enhancements = translated_props["enhancements"] + if enhancements.get("imbue"): + processed_item["imbue"] = enhancements["imbue"] + if enhancements.get("tinks", -1) != -1: + processed_item["tinks"] = enhancements["tinks"] + if enhancements.get("workmanship", -1.0) != -1.0: + processed_item["workmanship"] = enhancements["workmanship"] + processed_item["workmanship_text"] = translate_workmanship( + int(enhancements["workmanship"]) + ) + if enhancements.get("item_set"): + processed_item["item_set"] = enhancements["item_set"] # Add equipment set name translation - set_id = str(enhancements['item_set']).strip() - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + set_id = str(enhancements["item_set"]).strip() + dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) + attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get( + "values", {} + ) if set_id in attribute_set_info: - processed_item['item_set_name'] = attribute_set_info[set_id] + processed_item["item_set_name"] = attribute_set_info[set_id] else: - processed_item['item_set_name'] = f"Set {set_id}" + processed_item["item_set_name"] = f"Set {set_id}" # Add comprehensive ratings (use gear totals as fallback for armor/clothing) - if 'ratings' in translated_props: - ratings = translated_props['ratings'] + if "ratings" in translated_props: + ratings = translated_props["ratings"] # Damage rating: use individual rating or gear total - if ratings.get('damage_rating', -1) != -1: - processed_item['damage_rating'] = ratings['damage_rating'] - elif ratings.get('gear_damage', -1) > 0: - processed_item['damage_rating'] = ratings['gear_damage'] + if ratings.get("damage_rating", -1) != -1: + processed_item["damage_rating"] = ratings["damage_rating"] + elif ratings.get("gear_damage", -1) > 0: + processed_item["damage_rating"] = ratings["gear_damage"] # Crit rating - if ratings.get('crit_rating', -1) != -1: - processed_item['crit_rating'] = ratings['crit_rating'] - elif ratings.get('gear_crit', -1) > 0: - processed_item['crit_rating'] = ratings['gear_crit'] + if ratings.get("crit_rating", -1) != -1: + processed_item["crit_rating"] = ratings["crit_rating"] + elif ratings.get("gear_crit", -1) > 0: + processed_item["crit_rating"] = ratings["gear_crit"] # Crit damage rating: use individual rating or gear total - if ratings.get('crit_damage_rating', -1) != -1: - processed_item['crit_damage_rating'] = ratings['crit_damage_rating'] - elif ratings.get('gear_crit_damage', -1) > 0: - processed_item['crit_damage_rating'] = ratings['gear_crit_damage'] + if ratings.get("crit_damage_rating", -1) != -1: + processed_item["crit_damage_rating"] = ratings["crit_damage_rating"] + elif ratings.get("gear_crit_damage", -1) > 0: + processed_item["crit_damage_rating"] = ratings["gear_crit_damage"] # Heal boost rating: use individual rating or gear total - if ratings.get('heal_boost_rating', -1) != -1: - processed_item['heal_boost_rating'] = ratings['heal_boost_rating'] - elif ratings.get('gear_healing_boost', -1) > 0: - processed_item['heal_boost_rating'] = ratings['gear_healing_boost'] + if ratings.get("heal_boost_rating", -1) != -1: + processed_item["heal_boost_rating"] = ratings["heal_boost_rating"] + elif ratings.get("gear_healing_boost", -1) > 0: + processed_item["heal_boost_rating"] = ratings["gear_healing_boost"] # Apply material prefix to item name if material exists - if processed_item.get('material_name') and processed_item.get('name'): - original_name = processed_item['name'] - material_name = processed_item['material_name'] + if processed_item.get("material_name") and processed_item.get("name"): + original_name = processed_item["name"] + material_name = processed_item["material_name"] # Don't add prefix if name already starts with the material if not original_name.lower().startswith(material_name.lower()): - processed_item['name'] = f"{material_name} {original_name}" - processed_item['original_name'] = original_name # Preserve original for reference + processed_item["name"] = f"{material_name} {original_name}" + processed_item["original_name"] = ( + original_name # Preserve original for reference + ) # Remove raw data from response (keep clean output) - processed_item.pop('int_values', None) - processed_item.pop('double_values', None) - processed_item.pop('string_values', None) - processed_item.pop('bool_values', None) - processed_item.pop('original_json', None) + processed_item.pop("int_values", None) + processed_item.pop("double_values", None) + processed_item.pop("string_values", None) + processed_item.pop("bool_values", None) + processed_item.pop("original_json", None) # Remove null values for cleaner response processed_item = {k: v for k, v in processed_item.items() if v is not None} return processed_item -@app.get("/inventory/{character_name}", - summary="Get Character Inventory", - description="Retrieve processed inventory data for a specific character with normalized item properties.", - tags=["Character Data"]) +@app.get( + "/inventory/{character_name}", + summary="Get Character Inventory", + description="Retrieve processed inventory data for a specific character with normalized item properties.", + tags=["Character Data"], +) async def get_character_inventory( - character_name: str, - limit: int = Query(1000, le=5000), - offset: int = Query(0, ge=0) + character_name: str, limit: int = Query(1000, le=5000), offset: int = Query(0, ge=0) ): """Get processed inventory for a character with structured data and comprehensive translations.""" - + query = """ SELECT i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden, @@ -2048,30 +2488,33 @@ async def get_character_inventory( ORDER BY i.name LIMIT :limit OFFSET :offset """ - - items = await database.fetch_all(query, { - "character_name": character_name, - "limit": limit, - "offset": offset - }) - + + items = await database.fetch_all( + query, {"character_name": character_name, "limit": limit, "offset": offset} + ) + if not items: - raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}") - + raise HTTPException( + status_code=404, detail=f"No inventory found for {character_name}" + ) + # Convert to structured format with enhanced translations processed_items = [enrich_db_item(item) for item in items] - + return { "character_name": character_name, "item_count": len(processed_items), - "items": processed_items + "items": processed_items, } -@app.get("/health", - response_model=HealthResponse, - summary="Service Health Check", - description="Returns service health status, database connectivity, and version information.", - tags=["System"]) + +@app.get( + "/health", + response_model=HealthResponse, + summary="Service Health Check", + description="Returns service health status, database connectivity, and version information.", + tags=["System"], +) async def health_check(): """Health check endpoint with comprehensive status information.""" try: @@ -2080,18 +2523,20 @@ async def health_check(): db_connected = True except: db_connected = False - + return HealthResponse( status="healthy" if db_connected else "degraded", timestamp=datetime.now(), database_connected=db_connected, - version="1.0.0" + version="1.0.0", ) -@app.get("/sets/list", - response_model=SetListResponse, - summary="List Equipment Sets", - description=""" + +@app.get( + "/sets/list", + response_model=SetListResponse, + summary="List Equipment Sets", + description=""" **Get all unique equipment set names with item counts.** Returns a list of all equipment sets found in the database along with @@ -2106,7 +2551,8 @@ async def health_check(): - **40**: Heroic Protector - **41**: Heroic Destroyer """, - tags=["Equipment Sets"]) + tags=["Equipment Sets"], +) async def list_equipment_sets(): """Get all unique equipment set names with item counts from the database.""" try: @@ -2119,63 +2565,66 @@ async def list_equipment_sets(): ORDER BY item_count DESC, enh.item_set """ rows = await database.fetch_all(query) - + # Get AttributeSetInfo mapping from enum database - attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) - + attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get("values", {}) + # Map equipment sets to proper names equipment_sets = [] for row in rows: set_id = row["item_set"] item_count = row["item_count"] - + # Get proper name from AttributeSetInfo mapping set_name = attribute_set_info.get(set_id, f"Unknown Set {set_id}") - - equipment_sets.append({ - "id": set_id, - "name": set_name, - "item_count": item_count - }) - - return SetListResponse( - sets=equipment_sets - ) - + + equipment_sets.append( + {"id": set_id, "name": set_name, "item_count": item_count} + ) + + return SetListResponse(sets=equipment_sets) + except Exception as e: logger.error(f"Failed to list equipment sets: {e}") raise HTTPException(status_code=500, detail="Failed to list equipment sets") -@app.get("/enum-info", - summary="Get Enum Information", - description="Get comprehensive information about available enum translations and database statistics.", - tags=["System"]) +@app.get( + "/enum-info", + summary="Get Enum Information", + description="Get comprehensive information about available enum translations and database statistics.", + tags=["System"], +) async def get_enum_info(): """Get information about available enum translations.""" if ENUM_MAPPINGS is None: return {"error": "Enum database not loaded"} - + return { "available_enums": list(ENUM_MAPPINGS.keys()), - "int_values_count": len(ENUM_MAPPINGS.get('int_values', {})), - "materials_count": len(ENUM_MAPPINGS.get('materials', {})), - "item_types_count": len(ENUM_MAPPINGS.get('item_types', {})), - "skills_count": len(ENUM_MAPPINGS.get('skills', {})), - "spell_categories_count": len(ENUM_MAPPINGS.get('spell_categories', {})), - "spells_count": len(ENUM_MAPPINGS.get('spells', {})), - "object_classes_count": len(ENUM_MAPPINGS.get('object_classes', {})), - "coverage_masks_count": len(ENUM_MAPPINGS.get('coverage_masks', {})), - "database_version": ENUM_MAPPINGS.get('full_database', {}).get('metadata', {}).get('version', 'unknown') + "int_values_count": len(ENUM_MAPPINGS.get("int_values", {})), + "materials_count": len(ENUM_MAPPINGS.get("materials", {})), + "item_types_count": len(ENUM_MAPPINGS.get("item_types", {})), + "skills_count": len(ENUM_MAPPINGS.get("skills", {})), + "spell_categories_count": len(ENUM_MAPPINGS.get("spell_categories", {})), + "spells_count": len(ENUM_MAPPINGS.get("spells", {})), + "object_classes_count": len(ENUM_MAPPINGS.get("object_classes", {})), + "coverage_masks_count": len(ENUM_MAPPINGS.get("coverage_masks", {})), + "database_version": ENUM_MAPPINGS.get("full_database", {}) + .get("metadata", {}) + .get("version", "unknown"), } -@app.get("/translate/{enum_type}/{value}", - summary="Translate Enum Value", - description="Translate a specific enum value to human-readable name using the comprehensive enum database.", - tags=["System"]) + +@app.get( + "/translate/{enum_type}/{value}", + summary="Translate Enum Value", + description="Translate a specific enum value to human-readable name using the comprehensive enum database.", + tags=["System"], +) async def translate_enum_value(enum_type: str, value: int): """Translate a specific enum value to human-readable name.""" - + if enum_type == "material": return {"translation": translate_material_type(value)} elif enum_type == "item_type": @@ -2187,15 +2636,16 @@ async def translate_enum_value(enum_type: str, value: int): elif enum_type == "spell": return {"translation": translate_spell(value)} elif enum_type == "int_value": - int_enums = ENUM_MAPPINGS.get('int_values', {}) + int_enums = ENUM_MAPPINGS.get("int_values", {}) return {"translation": int_enums.get(value, f"unknown_{value}")} else: raise HTTPException(status_code=400, detail=f"Unknown enum type: {enum_type}") + @app.get("/inventory/{character_name}/raw") async def get_character_inventory_raw(character_name: str): """Get raw inventory data including comprehensive translations.""" - + query = """ SELECT i.name, i.icon, i.object_class, i.value, i.burden, i.timestamp, @@ -2207,20 +2657,22 @@ async def get_character_inventory_raw(character_name: str): ORDER BY i.name LIMIT 100 """ - + items = await database.fetch_all(query, {"character_name": character_name}) - + if not items: - raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}") - + raise HTTPException( + status_code=404, detail=f"No inventory found for {character_name}" + ) + # Process items with comprehensive translations processed_items = [] for item in items: item_dict = dict(item) - + # Add comprehensive translations for raw data - if item_dict.get('original_json'): - original_json = item_dict['original_json'] + if item_dict.get("original_json"): + original_json = item_dict["original_json"] # Handle case where original_json might be stored as string if isinstance(original_json, str): try: @@ -2228,17 +2680,17 @@ async def get_character_inventory_raw(character_name: str): except (json.JSONDecodeError, TypeError): logger.warning(f"Failed to parse original_json as JSON for item") original_json = {} - + if original_json: translations = get_comprehensive_translations(original_json) - item_dict['comprehensive_translations'] = translations - + item_dict["comprehensive_translations"] = translations + processed_items.append(item_dict) - + return { "character_name": character_name, "item_count": len(processed_items), - "items": processed_items + "items": processed_items, } @@ -2246,10 +2698,12 @@ async def get_character_inventory_raw(character_name: str): # INVENTORY SEARCH API ENDPOINTS # =================================================================== -@app.get("/search/items", - response_model=ItemSearchResponse, - summary="Advanced Item Search", - description=""" + +@app.get( + "/search/items", + response_model=ItemSearchResponse, + summary="Advanced Item Search", + description=""" **Comprehensive item search with extensive filtering capabilities.** This endpoint provides powerful search functionality across all character inventories @@ -2277,88 +2731,151 @@ async def get_character_inventory_raw(character_name: str): Returns paginated results with item details including translated properties, combat stats, spell information, and character ownership data. """, - tags=["Search"]) + tags=["Search"], +) async def search_items( # Text search - text: str = Query(None, description="Search item names, descriptions, or properties", example="Celdon"), - character: str = Query(None, description="Limit search to specific character", example="Megamula XXXIII"), - characters: str = Query(None, description="Comma-separated list of character names", example="Char1,Char2,Char3"), - include_all_characters: bool = Query(False, description="Search across all characters"), - - # Equipment filtering - equipment_status: str = Query(None, description="Filter by equipment status: 'equipped', 'unequipped', or omit for all", example="unequipped"), - equipment_slot: int = Query(None, description="Equipment slot mask (1=head, 2=neck, 4=chest, 8=abdomen, 16=upper_arms, 32=lower_arms, 64=hands, 128=upper_legs, 256=lower_legs, 512=feet, 1024=chest2, 2048=bracelet, 4096=ring)", example=4), - slot_names: str = Query(None, description="Comma-separated list of slot names", example="Head,Chest,Ring"), - + text: str = Query( + None, + description="Search item names, descriptions, or properties", + example="Celdon", + ), + character: str = Query( + None, + description="Limit search to specific character", + example="Megamula XXXIII", + ), + characters: str = Query( + None, + description="Comma-separated list of character names", + example="Char1,Char2,Char3", + ), + include_all_characters: bool = Query( + False, description="Search across all characters" + ), + # Equipment filtering + equipment_status: str = Query( + None, + description="Filter by equipment status: 'equipped', 'unequipped', or omit for all", + example="unequipped", + ), + equipment_slot: int = Query( + None, + description="Equipment slot mask (1=head, 2=neck, 4=chest, 8=abdomen, 16=upper_arms, 32=lower_arms, 64=hands, 128=upper_legs, 256=lower_legs, 512=feet, 1024=chest2, 2048=bracelet, 4096=ring)", + example=4, + ), + slot_names: str = Query( + None, + description="Comma-separated list of slot names", + example="Head,Chest,Ring", + ), # Item category filtering armor_only: bool = Query(False, description="Show only armor items"), jewelry_only: bool = Query(False, description="Show only jewelry items"), weapon_only: bool = Query(False, description="Show only weapon items"), - clothing_only: bool = Query(False, description="Show only clothing items (shirts/pants)"), + clothing_only: bool = Query( + False, description="Show only clothing items (shirts/pants)" + ), shirt_only: bool = Query(False, description="Show only shirt underclothes"), pants_only: bool = Query(False, description="Show only pants underclothes"), - underwear_only: bool = Query(False, description="Show all underclothes (shirts and pants)"), - + underwear_only: bool = Query( + False, description="Show all underclothes (shirts and pants)" + ), # Spell filtering - has_spell: str = Query(None, description="Must have this specific spell (by name)", example="Legendary Strength"), - spell_contains: str = Query(None, description="Spell name contains this text", example="Legendary"), - legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names", example="Legendary Strength,Legendary Endurance"), - + has_spell: str = Query( + None, + description="Must have this specific spell (by name)", + example="Legendary Strength", + ), + spell_contains: str = Query( + None, description="Spell name contains this text", example="Legendary" + ), + legendary_cantrips: str = Query( + None, + description="Comma-separated list of legendary cantrip names", + example="Legendary Strength,Legendary Endurance", + ), # Combat properties min_damage: int = Query(None, description="Minimum damage"), max_damage: int = Query(None, description="Maximum damage"), min_armor: int = Query(None, description="Minimum armor level"), max_armor: int = Query(None, description="Maximum armor level"), min_attack_bonus: float = Query(None, description="Minimum attack bonus"), - min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating (0-2)", example=2), - min_damage_rating: int = Query(None, description="Minimum damage rating (0-3)", example=3), + min_crit_damage_rating: int = Query( + None, description="Minimum critical damage rating (0-2)", example=2 + ), + min_damage_rating: int = Query( + None, description="Minimum damage rating (0-3)", example=3 + ), min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"), min_vitality_rating: int = Query(None, description="Minimum vitality rating"), - min_damage_resist_rating: int = Query(None, description="Minimum damage resist rating"), + min_damage_resist_rating: int = Query( + None, description="Minimum damage resist rating" + ), min_crit_resist_rating: int = Query(None, description="Minimum crit resist rating"), - min_crit_damage_resist_rating: int = Query(None, description="Minimum crit damage resist rating"), - min_healing_resist_rating: int = Query(None, description="Minimum healing resist rating"), - min_nether_resist_rating: int = Query(None, description="Minimum nether resist rating"), + min_crit_damage_resist_rating: int = Query( + None, description="Minimum crit damage resist rating" + ), + min_healing_resist_rating: int = Query( + None, description="Minimum healing resist rating" + ), + min_nether_resist_rating: int = Query( + None, description="Minimum nether resist rating" + ), min_healing_rating: int = Query(None, description="Minimum healing rating"), min_dot_resist_rating: int = Query(None, description="Minimum DoT resist rating"), min_life_resist_rating: int = Query(None, description="Minimum life resist rating"), - min_sneak_attack_rating: int = Query(None, description="Minimum sneak attack rating"), - min_recklessness_rating: int = Query(None, description="Minimum recklessness rating"), + min_sneak_attack_rating: int = Query( + None, description="Minimum sneak attack rating" + ), + min_recklessness_rating: int = Query( + None, description="Minimum recklessness rating" + ), min_deception_rating: int = Query(None, description="Minimum deception rating"), min_pk_damage_rating: int = Query(None, description="Minimum PvP damage rating"), - min_pk_damage_resist_rating: int = Query(None, description="Minimum PvP damage resist rating"), - min_gear_pk_damage_rating: int = Query(None, description="Minimum gear PvP damage rating"), - min_gear_pk_damage_resist_rating: int = Query(None, description="Minimum gear PvP damage resist rating"), - + min_pk_damage_resist_rating: int = Query( + None, description="Minimum PvP damage resist rating" + ), + min_gear_pk_damage_rating: int = Query( + None, description="Minimum gear PvP damage rating" + ), + min_gear_pk_damage_resist_rating: int = Query( + None, description="Minimum gear PvP damage resist rating" + ), # Requirements max_level: int = Query(None, description="Maximum wield level requirement"), min_level: int = Query(None, description="Minimum wield level requirement"), - # Enhancements - material: str = Query(None, description="Material type (partial match)", example="Gold"), - min_workmanship: float = Query(None, description="Minimum workmanship", example=9.5), + material: str = Query( + None, description="Material type (partial match)", example="Gold" + ), + min_workmanship: float = Query( + None, description="Minimum workmanship", example=9.5 + ), has_imbue: bool = Query(None, description="Has imbue effects"), item_set: str = Query(None, description="Item set ID (single set)", example="13"), - item_sets: str = Query(None, description="Comma-separated list of item set IDs", example="13,14,42"), + item_sets: str = Query( + None, description="Comma-separated list of item set IDs", example="13,14,42" + ), min_tinks: int = Query(None, description="Minimum tinker count", example=3), - # Item state bonded: bool = Query(None, description="Bonded status"), attuned: bool = Query(None, description="Attuned status"), unique: bool = Query(None, description="Unique item status"), is_rare: bool = Query(None, description="Rare item status"), min_condition: int = Query(None, description="Minimum condition percentage"), - # Value/utility min_value: int = Query(None, description="Minimum item value"), max_value: int = Query(None, description="Maximum item value"), max_burden: int = Query(None, description="Maximum burden"), - # Sorting and pagination - sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"), + sort_by: str = Query( + "name", + description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level", + ), sort_dir: str = Query("asc", description="Sort direction: asc or desc"), page: int = Query(1, ge=1, description="Page number"), - limit: int = Query(200, ge=1, le=50000, description="Items per page") + limit: int = Query(200, ge=1, le=50000, description="Items per page"), ): """ Search items across characters with comprehensive filtering options. @@ -2369,12 +2886,13 @@ async def search_items( if shirt_only: underwear_filter_type = "shirts" elif pants_only: - underwear_filter_type = "pants" + underwear_filter_type = "pants" elif underwear_only: underwear_filter_type = "all_underwear" - + # Build base query with CTE for computed slot names - query_parts = [""" + query_parts = [ + """ WITH items_with_slots AS ( SELECT DISTINCT i.id as db_item_id, @@ -2531,8 +3049,9 @@ async def search_items( LEFT JOIN item_raw_data rd ON i.id = rd.item_id ) SELECT * FROM items_with_slots - """] - + """ + ] + # Apply underwear filtering by modifying the CTE query if underwear_filter_type: # Insert WHERE clause into the CTE before the closing parenthesis @@ -2550,7 +3069,7 @@ async def search_items( AND i.name NOT ILIKE '%pants%' AND i.name NOT ILIKE '%breeches%'""" elif underwear_filter_type == "pants": - # Pants: ObjectClass 3 with UnderwearUpperLegs (2) - covers both pants and breeches + # Pants: ObjectClass 3 with UnderwearUpperLegs (2) - covers both pants and breeches # Include both full pants (2&4) and breeches (2 only) cte_where_clause = """WHERE i.object_class = 3 AND ((rd.int_values->>'218103821')::int & 2) = 2 @@ -2566,24 +3085,26 @@ async def search_items( AND i.name NOT ILIKE '%cloak%' AND i.name NOT ILIKE '%pallium%' AND i.name NOT ILIKE '%armet%'""" - + if cte_where_clause: # Insert the WHERE clause before the closing parenthesis of the CTE query_parts[0] = query_parts[0].replace( "LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n )", - f"LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n {cte_where_clause}\n )" + f"LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n {cte_where_clause}\n )", ) - + conditions = [] params = {} - + # Character filtering if character: conditions.append("character_name = :character") params["character"] = character elif characters: # Handle comma-separated list of characters - character_list = [char.strip() for char in characters.split(',') if char.strip()] + character_list = [ + char.strip() for char in characters.split(",") if char.strip() + ] if character_list: # Create parameterized IN clause char_params = [] @@ -2596,16 +3117,16 @@ async def search_items( return { "error": "Empty characters list provided", "items": [], - "total_count": 0 + "total_count": 0, } elif not include_all_characters: # Default to requiring character parameter if not searching all return { "error": "Must specify character, characters, or set include_all_characters=true", "items": [], - "total_count": 0 + "total_count": 0, } - + # Text search (name with material support) if text: # Search both the concatenated material+name and base name @@ -2616,7 +3137,7 @@ async def search_items( COALESCE(material, '') ILIKE :text )""") params["text"] = f"%{text}%" - + # Item category filtering if armor_only: # Armor: ObjectClass 2 (Armor) with armor_level > 0 @@ -2626,7 +3147,9 @@ async def search_items( conditions.append("object_class = 4") elif weapon_only: # Weapons: ObjectClass 6 (MeleeWeapon), 7 (MissileWeapon), 8 (Caster) with max_damage > 0 - conditions.append("(object_class IN (6, 7, 8) AND COALESCE(max_damage, 0) > 0)") + conditions.append( + "(object_class IN (6, 7, 8) AND COALESCE(max_damage, 0) > 0)" + ) elif clothing_only: # Clothing: ObjectClass 3 (Clothing) - shirts and pants only, exclude cloaks and robes # Focus on underclothes: shirts, pants, breeches, etc. @@ -2637,148 +3160,180 @@ async def search_items( name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))""") # Underwear filtering is handled in the CTE modification above - + # Spell filtering - need to join with item_spells and use spell database spell_join_added = False if has_spell or spell_contains or legendary_cantrips: query_parts[0] = query_parts[0].replace( "LEFT JOIN item_ratings rt ON i.id = rt.item_id", """LEFT JOIN item_ratings rt ON i.id = rt.item_id - LEFT JOIN item_spells sp ON i.id = sp.item_id""" + LEFT JOIN item_spells sp ON i.id = sp.item_id""", ) spell_join_added = True - + spell_conditions = [] - + if has_spell: # Look up spell ID by exact name match in ENUM_MAPPINGS spell_id = None - spells = ENUM_MAPPINGS.get('spells', {}) + spells = ENUM_MAPPINGS.get("spells", {}) for sid, spell_data in spells.items(): - if isinstance(spell_data, dict) and spell_data.get('name', '').lower() == has_spell.lower(): + if ( + isinstance(spell_data, dict) + and spell_data.get("name", "").lower() == has_spell.lower() + ): spell_id = sid break - + if spell_id: spell_conditions.append("sp.spell_id = :has_spell_id") params["has_spell_id"] = spell_id else: # If spell not found by exact name, no results conditions.append("1 = 0") - + if spell_contains: # Find all spell IDs that contain the text matching_spell_ids = [] - spells = ENUM_MAPPINGS.get('spells', {}) + spells = ENUM_MAPPINGS.get("spells", {}) for sid, spell_data in spells.items(): if isinstance(spell_data, dict): - spell_name = spell_data.get('name', '').lower() + spell_name = spell_data.get("name", "").lower() if spell_contains.lower() in spell_name: matching_spell_ids.append(sid) - + if matching_spell_ids: - spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})") + spell_conditions.append( + f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})" + ) else: # If no spells found containing the text, no results conditions.append("1 = 0") - + if legendary_cantrips: # Parse comma-separated list of cantrip names - cantrip_names = [name.strip() for name in legendary_cantrips.split(',')] + cantrip_names = [name.strip() for name in legendary_cantrips.split(",")] matching_spell_ids = [] - spells = ENUM_MAPPINGS.get('spells', {}) - + spells = ENUM_MAPPINGS.get("spells", {}) + logger.info(f"Looking for cantrips: {cantrip_names}") - + for cantrip_name in cantrip_names: found_match = False for sid, spell_data in spells.items(): if isinstance(spell_data, dict): - spell_name = spell_data.get('name', '').lower() + spell_name = spell_data.get("name", "").lower() # Match cantrip name (flexible matching) - if cantrip_name.lower() in spell_name or spell_name in cantrip_name.lower(): + if ( + cantrip_name.lower() in spell_name + or spell_name in cantrip_name.lower() + ): matching_spell_ids.append(sid) found_match = True - logger.info(f"Found spell match: {spell_name} (ID: {sid}) for cantrip: {cantrip_name}") - + logger.info( + f"Found spell match: {spell_name} (ID: {sid}) for cantrip: {cantrip_name}" + ) + if not found_match: - logger.warning(f"No spell found matching cantrip: {cantrip_name}") - + logger.warning( + f"No spell found matching cantrip: {cantrip_name}" + ) + if matching_spell_ids: # Remove duplicates matching_spell_ids = list(set(matching_spell_ids)) - + # CONSTRAINT SATISFACTION: Items must have ALL selected spells (AND logic) # Use EXISTS subquery to ensure all required spells are present - num_required_spells = len(cantrip_names) # Number of different cantrips user selected + num_required_spells = len( + cantrip_names + ) # Number of different cantrips user selected spell_conditions.append(f"""( SELECT COUNT(DISTINCT sp2.spell_id) FROM item_spells sp2 WHERE sp2.item_id = db_item_id - AND sp2.spell_id IN ({','.join(map(str, matching_spell_ids))}) + AND sp2.spell_id IN ({",".join(map(str, matching_spell_ids))}) ) >= {num_required_spells}""") - logger.info(f"Constraint satisfaction: Items must have ALL {num_required_spells} cantrips from IDs: {matching_spell_ids}") + logger.info( + f"Constraint satisfaction: Items must have ALL {num_required_spells} cantrips from IDs: {matching_spell_ids}" + ) else: # If no matching cantrips found, this will return no results - logger.warning("No matching spells found for any cantrips - search will return empty results") + logger.warning( + "No matching spells found for any cantrips - search will return empty results" + ) spell_conditions.append("1 = 0") # Use impossible condition - + # Add spell constraints (now using AND logic for constraint satisfaction) if spell_conditions: conditions.extend(spell_conditions) - + # Equipment status if equipment_status == "equipped": conditions.append("current_wielded_location > 0") elif equipment_status == "unequipped": conditions.append("current_wielded_location = 0") - + # Equipment slot if equipment_slot is not None: conditions.append("current_wielded_location = :equipment_slot") params["equipment_slot"] = equipment_slot - + # Slot names filtering - use multiple approaches for better coverage if slot_names: - slot_list = [slot.strip() for slot in slot_names.split(',') if slot.strip()] + slot_list = [slot.strip() for slot in slot_names.split(",") if slot.strip()] if slot_list: slot_conditions = [] for i, slot_name in enumerate(slot_list): param_name = f"slot_{i}" - + # Multiple filtering approaches for better coverage slot_approaches = [] - + # Approach 1: Check computed_slot_name - if slot_name.lower() == 'ring': + if slot_name.lower() == "ring": slot_approaches.append("(computed_slot_name ILIKE '%Ring%')") - elif slot_name.lower() in ['bracelet', 'wrist']: + elif slot_name.lower() in ["bracelet", "wrist"]: slot_approaches.append("(computed_slot_name ILIKE '%Wrist%')") else: - slot_approaches.append(f"(computed_slot_name ILIKE :{param_name})") + slot_approaches.append( + f"(computed_slot_name ILIKE :{param_name})" + ) params[param_name] = f"%{slot_name}%" - + # Approach 2: Specific jewelry logic for common cases - if slot_name.lower() == 'neck': + if slot_name.lower() == "neck": # For neck: object_class = 4 (jewelry) with amulet/necklace/gorget names - slot_approaches.append("(object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%'))") - elif slot_name.lower() == 'ring': + slot_approaches.append( + "(object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%'))" + ) + elif slot_name.lower() == "ring": # For rings: object_class = 4 with ring in name (excluding keyrings) - slot_approaches.append("(object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%')") - elif slot_name.lower() in ['bracelet', 'wrist']: + slot_approaches.append( + "(object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%')" + ) + elif slot_name.lower() in ["bracelet", "wrist"]: # For bracelets: object_class = 4 with bracelet in name - slot_approaches.append("(object_class = 4 AND name ILIKE '%bracelet%')") - elif slot_name.lower() == 'trinket': + slot_approaches.append( + "(object_class = 4 AND name ILIKE '%bracelet%')" + ) + elif slot_name.lower() == "trinket": # For trinkets: multiple approaches # 1. Equipped trinkets have specific wielded_location slot_approaches.append("(current_wielded_location = 67108864)") # 2. Jewelry with trinket-related names - slot_approaches.append("(object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%'))") + slot_approaches.append( + "(object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%'))" + ) # 3. Gems with trinket names - slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')") + slot_approaches.append( + "(object_class = 11 AND name ILIKE '%trinket%')" + ) # 4. Jewelry fallback: items that don't match other jewelry patterns - slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')") - elif slot_name.lower() == 'cloak': + slot_approaches.append( + "(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')" + ) + elif slot_name.lower() == "cloak": # For cloaks: identify by name pattern slot_approaches.append("(name ILIKE '%cloak%')") slot_approaches.append("(computed_slot_name = 'Cloak')") @@ -2786,12 +3341,14 @@ async def search_items( # Combine approaches with OR (any approach can match) if slot_approaches: slot_conditions.append(f"({' OR '.join(slot_approaches)})") - + # Use OR logic for slots (items matching ANY selected slot) if slot_conditions: conditions.append(f"({' OR '.join(slot_conditions)})") - logger.info(f"Slot filtering: Looking for items with slots: {slot_list}") - + logger.info( + f"Slot filtering: Looking for items with slots: {slot_list}" + ) + # Combat properties if min_damage is not None: conditions.append("max_damage >= :min_damage") @@ -2827,7 +3384,9 @@ async def search_items( conditions.append("crit_resist_rating >= :min_crit_resist_rating") params["min_crit_resist_rating"] = min_crit_resist_rating if min_crit_damage_resist_rating is not None: - conditions.append("crit_damage_resist_rating >= :min_crit_damage_resist_rating") + conditions.append( + "crit_damage_resist_rating >= :min_crit_damage_resist_rating" + ) params["min_crit_damage_resist_rating"] = min_crit_damage_resist_rating if min_healing_resist_rating is not None: conditions.append("healing_resist_rating >= :min_healing_resist_rating") @@ -2863,9 +3422,13 @@ async def search_items( conditions.append("gear_pk_damage_rating >= :min_gear_pk_damage_rating") params["min_gear_pk_damage_rating"] = min_gear_pk_damage_rating if min_gear_pk_damage_resist_rating is not None: - conditions.append("gear_pk_damage_resist_rating >= :min_gear_pk_damage_resist_rating") - params["min_gear_pk_damage_resist_rating"] = min_gear_pk_damage_resist_rating - + conditions.append( + "gear_pk_damage_resist_rating >= :min_gear_pk_damage_resist_rating" + ) + params["min_gear_pk_damage_resist_rating"] = ( + min_gear_pk_damage_resist_rating + ) + # Requirements if max_level is not None: conditions.append("(wield_level <= :max_level OR wield_level IS NULL)") @@ -2873,7 +3436,7 @@ async def search_items( if min_level is not None: conditions.append("wield_level >= :min_level") params["min_level"] = min_level - + # Enhancements if material: conditions.append("material ILIKE :material") @@ -2889,23 +3452,31 @@ async def search_items( if item_set: # Translate set ID to set name for database matching set_name = translate_equipment_set_id(item_set) - logger.info(f"Translated equipment set ID '{item_set}' to name '{set_name}'") + logger.info( + f"Translated equipment set ID '{item_set}' to name '{set_name}'" + ) conditions.append("item_set = :item_set") params["item_set"] = set_name elif item_sets: # Handle comma-separated list of item set IDs - set_list = [set_id.strip() for set_id in item_sets.split(',') if set_id.strip()] + set_list = [ + set_id.strip() for set_id in item_sets.split(",") if set_id.strip() + ] if set_list: # CONSTRAINT SATISFACTION: Multiple equipment sets if len(set_list) > 1: # IMPOSSIBLE CONSTRAINT: Item cannot be in multiple sets simultaneously - logger.info(f"Multiple equipment sets selected: {set_list} - This is impossible, returning no results") + logger.info( + f"Multiple equipment sets selected: {set_list} - This is impossible, returning no results" + ) conditions.append("1 = 0") # No results for impossible constraint else: # Single set selection - normal behavior set_id = set_list[0] set_name = translate_equipment_set_id(set_id) - logger.info(f"Translated equipment set ID '{set_id}' to name '{set_name}'") + logger.info( + f"Translated equipment set ID '{set_id}' to name '{set_name}'" + ) conditions.append("item_set = :item_set") params["item_set"] = set_name else: @@ -2914,7 +3485,7 @@ async def search_items( if min_tinks is not None: conditions.append("tinks >= :min_tinks") params["min_tinks"] = min_tinks - + # Item state if bonded is not None: conditions.append("bonded > 0" if bonded else "bonded = 0") @@ -2929,9 +3500,11 @@ async def search_items( else: conditions.append("(rare_id IS NULL OR rare_id <= 0)") if min_condition is not None: - conditions.append("((structure * 100.0 / NULLIF(max_structure, 0)) >= :min_condition OR max_structure IS NULL)") + conditions.append( + "((structure * 100.0 / NULLIF(max_structure, 0)) >= :min_condition OR max_structure IS NULL)" + ) params["min_condition"] = min_condition - + # Value/utility if min_value is not None: conditions.append("value >= :min_value") @@ -2942,11 +3515,11 @@ async def search_items( if max_burden is not None: conditions.append("burden <= :max_burden") params["max_burden"] = max_burden - + # Build WHERE clause if conditions: query_parts.append("WHERE " + " AND ".join(conditions)) - + # Add ORDER BY sort_mapping = { "name": "name", @@ -2968,25 +3541,28 @@ async def search_items( "coverage": "coverage_mask", "item_type_name": "object_class", "last_updated": "timestamp", - "spell_names": "computed_spell_names" + "spell_names": "computed_spell_names", } sort_field = sort_mapping.get(sort_by, "name") sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" # Handle NULLS for optional fields nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST" - query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id") - + query_parts.append( + f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id" + ) + # Add pagination offset = (page - 1) * limit query_parts.append(f"LIMIT {limit} OFFSET {offset}") - + # Execute query query = "\n".join(query_parts) rows = await database.fetch_all(query, params) - + # Get total count for pagination - use same CTE structure - count_query_parts = [""" + count_query_parts = [ + """ WITH items_with_slots AS ( SELECT DISTINCT i.id as db_item_id, @@ -3082,220 +3658,269 @@ async def search_items( LEFT JOIN item_enhancements enh ON i.id = enh.item_id LEFT JOIN item_ratings rt ON i.id = rt.item_id LEFT JOIN item_raw_data rd ON i.id = rd.item_id - """] - + """ + ] + # Add spell join to count query if needed if spell_join_added: count_query_parts.append("LEFT JOIN item_spells sp ON i.id = sp.item_id") - + count_query_parts.append(""" ) SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots """) - + if conditions: count_query_parts.append("WHERE " + " AND ".join(conditions)) - + count_query = "\n".join(count_query_parts) - + count_result = await database.fetch_one(count_query, params) total_count = int(count_result[0]) if count_result else 0 - + # Format results with comprehensive translations (like individual inventory endpoint) items = [] for row in rows: item = dict(row) - + # Add computed properties - item['is_equipped'] = item['current_wielded_location'] > 0 - item['is_bonded'] = item['bonded'] > 0 - item['is_attuned'] = item['attuned'] > 0 - item['is_rare'] = (item['rare_id'] or 0) > 0 - + item["is_equipped"] = item["current_wielded_location"] > 0 + item["is_bonded"] = item["bonded"] > 0 + item["is_attuned"] = item["attuned"] > 0 + item["is_rare"] = (item["rare_id"] or 0) > 0 + # Calculate condition percentage - if item['max_structure'] and item['max_structure'] > 0: - item['condition_percent'] = round((item['structure'] or 0) * 100.0 / item['max_structure'], 1) + if item["max_structure"] and item["max_structure"] > 0: + item["condition_percent"] = round( + (item["structure"] or 0) * 100.0 / item["max_structure"], 1 + ) else: - item['condition_percent'] = None - + item["condition_percent"] = None + # Apply comprehensive translations from original_json (like individual inventory endpoint) - if item.get('original_json'): - original_json = item['original_json'] + if item.get("original_json"): + original_json = item["original_json"] # Handle case where original_json might be stored as string if isinstance(original_json, str): try: original_json = json.loads(original_json) except (json.JSONDecodeError, TypeError): original_json = {} - + if original_json: # Extract properties and get comprehensive translations properties = extract_item_properties(original_json) - + # Add material translation and prefixing - if item.get('material') or properties.get('translations', {}).get('material_name'): + if item.get("material") or properties.get("translations", {}).get( + "material_name" + ): material_name = None - if item.get('material'): + if item.get("material"): # Check if material is already a string or needs translation - if isinstance(item['material'], str): - material_name = item['material'] + if isinstance(item["material"], str): + material_name = item["material"] else: - material_name = translate_material_type(item['material']) - elif properties.get('translations', {}).get('material_name'): - material_name = properties['translations']['material_name'] - - if material_name and not material_name.startswith('Unknown_Material_'): - item['material_name'] = material_name + material_name = translate_material_type( + item["material"] + ) + elif properties.get("translations", {}).get("material_name"): + material_name = properties["translations"]["material_name"] + + if material_name and not material_name.startswith( + "Unknown_Material_" + ): + item["material_name"] = material_name # Apply material prefix to item name - original_name = item['name'] - if not original_name.lower().startswith(material_name.lower()): - item['name'] = f"{material_name} {original_name}" - item['original_name'] = original_name - + original_name = item["name"] + if not original_name.lower().startswith( + material_name.lower() + ): + item["name"] = f"{material_name} {original_name}" + item["original_name"] = original_name + # Add object class translation - if item.get('object_class'): - item['object_class_name'] = translate_object_class(item['object_class'], original_json) - + if item.get("object_class"): + item["object_class_name"] = translate_object_class( + item["object_class"], original_json + ) + # Add item type translation - if properties.get('translations', {}).get('item_type_name'): - item['item_type_name'] = properties['translations']['item_type_name'] - elif item.get('object_class'): + if properties.get("translations", {}).get("item_type_name"): + item["item_type_name"] = properties["translations"][ + "item_type_name" + ] + elif item.get("object_class"): # Fallback: derive ItemType from object_class when translation is missing - item['item_type_name'] = derive_item_type_from_object_class(item['object_class'], {'Name': item.get('name', '')}) - + item["item_type_name"] = derive_item_type_from_object_class( + item["object_class"], {"Name": item.get("name", "")} + ) + # Add spell information - if 'spells' in properties: - spell_info = properties['spells'] - if spell_info.get('spells'): - item['spells'] = spell_info['spells'] - item['spell_names'] = [spell.get('name', '') for spell in spell_info['spells'] if spell.get('name')] - if spell_info.get('active_spells'): - item['active_spells'] = spell_info['active_spells'] - + if "spells" in properties: + spell_info = properties["spells"] + if spell_info.get("spells"): + item["spells"] = spell_info["spells"] + item["spell_names"] = [ + spell.get("name", "") + for spell in spell_info["spells"] + if spell.get("name") + ] + if spell_info.get("active_spells"): + item["active_spells"] = spell_info["active_spells"] + # Add coverage calculation from coverage mask - int_values = original_json.get('IntValues', {}) + int_values = original_json.get("IntValues", {}) coverage_value = None - + # Check for coverage mask in correct location (218103821 = Coverage_Decal) - if '218103821' in int_values: - coverage_value = int_values['218103821'] + if "218103821" in int_values: + coverage_value = int_values["218103821"] elif 218103821 in int_values: coverage_value = int_values[218103821] - + if coverage_value and coverage_value > 0: coverage_parts = translate_coverage_mask(coverage_value) if coverage_parts: - item['coverage'] = ', '.join(coverage_parts) + item["coverage"] = ", ".join(coverage_parts) else: - item['coverage'] = f"Coverage_{coverage_value}" + item["coverage"] = f"Coverage_{coverage_value}" # Add raw coverage mask for armor reduction system - item['coverage_mask'] = coverage_value + item["coverage_mask"] = coverage_value else: - item['coverage'] = None - item['coverage_mask'] = 0 - + item["coverage"] = None + item["coverage_mask"] = 0 + # Add sophisticated equipment slot translation using Mag-SuitBuilder logic # Use both EquipableSlots_Decal and Coverage for armor reduction - if original_json and 'IntValues' in original_json: - equippable_slots = int_values.get('218103822', int_values.get(218103822, 0)) - coverage_value = int_values.get('218103821', int_values.get(218103821, 0)) - + if original_json and "IntValues" in original_json: + equippable_slots = int_values.get( + "218103822", int_values.get(218103822, 0) + ) + coverage_value = int_values.get( + "218103821", int_values.get(218103821, 0) + ) + # Add debug info to help troubleshoot slot translation issues - if 'legging' in item['name'].lower() or 'greave' in item['name'].lower(): - item['debug_slot_info'] = { - 'equippable_slots': equippable_slots, - 'coverage_value': coverage_value, - 'current_wielded_location': item.get('current_wielded_location', 0) + if ( + "legging" in item["name"].lower() + or "greave" in item["name"].lower() + ): + item["debug_slot_info"] = { + "equippable_slots": equippable_slots, + "coverage_value": coverage_value, + "current_wielded_location": item.get( + "current_wielded_location", 0 + ), } - + if equippable_slots and int(equippable_slots) > 0: # Check if item has material (can be tailored) - has_material = bool(item.get('material_name') and item.get('material_name') != '') - + has_material = bool( + item.get("material_name") + and item.get("material_name") != "" + ) + # Get sophisticated slot options using Mag-SuitBuilder logic slot_options = get_sophisticated_slot_options( - int(equippable_slots), + int(equippable_slots), int(coverage_value) if coverage_value else 0, - has_material + has_material, ) - + # Translate all slot options to friendly names slot_names = [] for slot_option in slot_options: slot_name = translate_equipment_slot(slot_option) if slot_name and slot_name not in slot_names: slot_names.append(slot_name) - + # Debug logging for slot issues if not slot_names and equippable_slots in [30, 786432]: - logger.warning(f"No slot names found for item '{item['name']}' with equippable_slots={equippable_slots}, slot_options={slot_options}") - - item['slot_name'] = ', '.join(slot_names) if slot_names else "-" + logger.warning( + f"No slot names found for item '{item['name']}' with equippable_slots={equippable_slots}, slot_options={slot_options}" + ) + + item["slot_name"] = ( + ", ".join(slot_names) if slot_names else "-" + ) else: - item['slot_name'] = "-" + item["slot_name"] = "-" else: - item['slot_name'] = "-" - + item["slot_name"] = "-" + # Use gear totals as display ratings when individual ratings don't exist - # For armor/clothing, ratings are often stored as gear totals (370, 372, 374) - if item.get('damage_rating', -1) == -1 and 'gear_damage' in properties.get('ratings', {}): - gear_damage = properties['ratings'].get('gear_damage', -1) + # For armor/clothing, ratings are often stored as gear totals (370, 372, 374) + if item.get( + "damage_rating", -1 + ) == -1 and "gear_damage" in properties.get("ratings", {}): + gear_damage = properties["ratings"].get("gear_damage", -1) if gear_damage > 0: - item['damage_rating'] = gear_damage + item["damage_rating"] = gear_damage else: - item['damage_rating'] = None - elif item.get('damage_rating', -1) == -1: - item['damage_rating'] = None - - if item.get('crit_damage_rating', -1) == -1 and 'gear_crit_damage' in properties.get('ratings', {}): - gear_crit_damage = properties['ratings'].get('gear_crit_damage', -1) + item["damage_rating"] = None + elif item.get("damage_rating", -1) == -1: + item["damage_rating"] = None + + if item.get( + "crit_damage_rating", -1 + ) == -1 and "gear_crit_damage" in properties.get("ratings", {}): + gear_crit_damage = properties["ratings"].get( + "gear_crit_damage", -1 + ) if gear_crit_damage > 0: - item['crit_damage_rating'] = gear_crit_damage + item["crit_damage_rating"] = gear_crit_damage else: - item['crit_damage_rating'] = None - elif item.get('crit_damage_rating', -1) == -1: - item['crit_damage_rating'] = None - - if item.get('heal_boost_rating', -1) == -1 and 'gear_healing_boost' in properties.get('ratings', {}): - gear_healing_boost = properties['ratings'].get('gear_healing_boost', -1) + item["crit_damage_rating"] = None + elif item.get("crit_damage_rating", -1) == -1: + item["crit_damage_rating"] = None + + if item.get( + "heal_boost_rating", -1 + ) == -1 and "gear_healing_boost" in properties.get("ratings", {}): + gear_healing_boost = properties["ratings"].get( + "gear_healing_boost", -1 + ) if gear_healing_boost > 0: - item['heal_boost_rating'] = gear_healing_boost + item["heal_boost_rating"] = gear_healing_boost else: - item['heal_boost_rating'] = None - elif item.get('heal_boost_rating', -1) == -1: - item['heal_boost_rating'] = None - + item["heal_boost_rating"] = None + elif item.get("heal_boost_rating", -1) == -1: + item["heal_boost_rating"] = None + # Add equipment set name translation - if item.get('item_set') and str(item['item_set']).strip(): - set_id = str(item['item_set']).strip() + if item.get("item_set") and str(item["item_set"]).strip(): + set_id = str(item["item_set"]).strip() # Get dictionary from enum database - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) + attribute_set_info = dictionaries.get( + "AttributeSetInfo", {} + ).get("values", {}) if set_id in attribute_set_info: - item['item_set_name'] = attribute_set_info[set_id] + item["item_set_name"] = attribute_set_info[set_id] else: # Try checking if it's in the alternative location (equipment_sets) - equipment_sets = ENUM_MAPPINGS.get('equipment_sets', {}) + equipment_sets = ENUM_MAPPINGS.get("equipment_sets", {}) if set_id in equipment_sets: - item['item_set_name'] = equipment_sets[set_id] + item["item_set_name"] = equipment_sets[set_id] else: - item['item_set_name'] = f"Set {set_id}" - + item["item_set_name"] = f"Set {set_id}" + # Clean up - remove raw data from response - item.pop('original_json', None) - item.pop('db_item_id', None) - + item.pop("original_json", None) + item.pop("db_item_id", None) + items.append(item) - + return ItemSearchResponse( items=items, total_count=total_count, page=page, limit=limit, has_next=page * limit < total_count, - has_previous=page > 1 + has_previous=page > 1, ) - + except Exception as e: logger.error(f"Search error: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") @@ -3304,18 +3929,22 @@ async def search_items( @app.get("/search/equipped/{character_name}") async def search_equipped_items( character_name: str, - slot: int = Query(None, description="Specific equipment slot mask") + slot: int = Query(None, description="Specific equipment slot mask"), ): """Get all equipped items for a character, optionally filtered by slot.""" try: - conditions = ["i.character_name = :character_name", "i.current_wielded_location > 0"] + conditions = [ + "i.character_name = :character_name", + "i.current_wielded_location > 0", + ] params = {"character_name": character_name} - + if slot is not None: conditions.append("i.current_wielded_location = :slot") params["slot"] = slot - - query = """ + + query = ( + """ SELECT i.*, COALESCE(cs.max_damage, -1) as max_damage, @@ -3329,45 +3958,55 @@ async def search_equipped_items( LEFT JOIN item_combat_stats cs ON i.id = cs.item_id LEFT JOIN item_requirements req ON i.id = req.item_id LEFT JOIN item_enhancements enh ON i.id = enh.item_id - WHERE """ + " AND ".join(conditions) + """ + WHERE """ + + " AND ".join(conditions) + + """ ORDER BY i.current_wielded_location, i.name """ - + ) + rows = await database.fetch_all(query, params) - + # Load EquipMask enum for slot names equip_mask_map = {} - if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): - equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] + if "EquipMask" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): + equip_data = ENUM_MAPPINGS["full_database"]["enums"]["EquipMask"]["values"] for k, v in equip_data.items(): try: equip_mask_map[int(k)] = v except (ValueError, TypeError): pass - + items = [] for row in rows: item = dict(row) - item['slot_name'] = equip_mask_map.get(item['current_wielded_location'], f"Slot_{item['current_wielded_location']}") + item["slot_name"] = equip_mask_map.get( + item["current_wielded_location"], + f"Slot_{item['current_wielded_location']}", + ) items.append(item) - + return { "character_name": character_name, "equipped_items": items, "slot_filter": slot, - "item_count": len(items) + "item_count": len(items), } - + except Exception as e: logger.error(f"Equipped items search error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Equipped items search failed: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Equipped items search failed: {str(e)}" + ) @app.get("/search/upgrades/{character_name}/{slot}") async def find_equipment_upgrades( character_name: str, slot: int, - upgrade_type: str = Query("damage", description="What to optimize for: damage, armor, workmanship, value") + upgrade_type: str = Query( + "damage", description="What to optimize for: damage, armor, workmanship, value" + ), ): """Find potential equipment upgrades for a specific slot.""" try: @@ -3380,12 +4019,11 @@ async def find_equipment_upgrades( WHERE i.character_name = :character_name AND i.current_wielded_location = :slot """ - - current_item = await database.fetch_one(current_query, { - "character_name": character_name, - "slot": slot - }) - + + current_item = await database.fetch_one( + current_query, {"character_name": character_name, "slot": slot} + ) + # Find all unequipped items that could be equipped in this slot # Check ValidLocations or infer from similar equipped items upgrade_query = """ @@ -3405,11 +4043,11 @@ async def find_equipment_upgrades( WHERE i.current_wielded_location = 0 AND i.object_class = :object_class """ - + params = {} if current_item: params["object_class"] = current_item["object_class"] - + # Add upgrade criteria based on current item if upgrade_type == "damage" and current_item.get("max_damage", -1) > 0: upgrade_query += " AND cs.max_damage > :current_damage" @@ -3417,7 +4055,10 @@ async def find_equipment_upgrades( elif upgrade_type == "armor" and current_item.get("armor_level", -1) > 0: upgrade_query += " AND cs.armor_level > :current_armor" params["current_armor"] = current_item["armor_level"] - elif upgrade_type == "workmanship" and current_item.get("workmanship", -1) > 0: + elif ( + upgrade_type == "workmanship" + and current_item.get("workmanship", -1) > 0 + ): upgrade_query += " AND enh.workmanship > :current_workmanship" params["current_workmanship"] = current_item["workmanship"] elif upgrade_type == "value": @@ -3427,39 +4068,43 @@ async def find_equipment_upgrades( # No current item, show all available items for this slot type # We'll need to infer object class from slot - this is a simplified approach params["object_class"] = 1 # Default to generic - + # Add sorting based on upgrade type if upgrade_type == "damage": upgrade_query += " ORDER BY cs.max_damage DESC" elif upgrade_type == "armor": - upgrade_query += " ORDER BY cs.armor_level DESC" + upgrade_query += " ORDER BY cs.armor_level DESC" elif upgrade_type == "workmanship": upgrade_query += " ORDER BY enh.workmanship DESC" else: upgrade_query += " ORDER BY i.value DESC" - + upgrade_query += " LIMIT 20" # Limit to top 20 upgrades - + upgrades = await database.fetch_all(upgrade_query, params) - + return { "character_name": character_name, "slot": slot, "upgrade_type": upgrade_type, "current_item": dict(current_item) if current_item else None, "potential_upgrades": [dict(row) for row in upgrades], - "upgrade_count": len(upgrades) + "upgrade_count": len(upgrades), } - + except Exception as e: logger.error(f"Equipment upgrades search error: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Equipment upgrades search failed: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Equipment upgrades search failed: {str(e)}" + ) -@app.get("/characters/list", - summary="List Characters", - description="Get a list of all characters that have inventory data in the database.", - tags=["Character Data"]) +@app.get( + "/characters/list", + summary="List Characters", + description="Get a list of all characters that have inventory data in the database.", + tags=["Character Data"], +) async def list_inventory_characters(): """List all characters that have inventory data.""" try: @@ -3470,69 +4115,72 @@ async def list_inventory_characters(): ORDER BY character_name """ rows = await database.fetch_all(query) - + characters = [] for row in rows: - characters.append({ - "character_name": row["character_name"], - "item_count": row["item_count"], - "last_updated": row["last_updated"] - }) - - return { - "characters": characters, - "total_characters": len(characters) - } - + characters.append( + { + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"], + } + ) + + return {"characters": characters, "total_characters": len(characters)} + except Exception as e: logger.error(f"Failed to list inventory characters: {e}") - raise HTTPException(status_code=500, detail="Failed to list inventory characters") + raise HTTPException( + status_code=500, detail="Failed to list inventory characters" + ) @app.get("/search/by-slot") async def search_items_by_slot( - slot: str = Query(..., description="Slot to search for (e.g., 'Head', 'Chest', 'Hands')"), - characters: str = Query(None, description="Comma-separated list of character names"), + slot: str = Query( + ..., description="Slot to search for (e.g., 'Head', 'Chest', 'Hands')" + ), + characters: str = Query( + None, description="Comma-separated list of character names" + ), include_all_characters: bool = Query(False, description="Include all characters"), - # Equipment type armor_only: bool = Query(True, description="Show only armor items"), - # Pagination limit: int = Query(100, le=1000), - offset: int = Query(0, ge=0) + offset: int = Query(0, ge=0), ): """Search for items that can be equipped in a specific slot.""" try: # Build query conditions = [] params = {} - + # TODO: Implement slot filtering once we have slot_name in DB # For now, return message about missing implementation if slot: return { "error": "Slot-based search not yet implemented", "message": f"Cannot search for slot '{slot}' - slot_name field needs to be added to database", - "suggestion": "Use regular /search/items endpoint with filters for now" + "suggestion": "Use regular /search/items endpoint with filters for now", } - + # Character filtering if not include_all_characters and characters: - char_list = [c.strip() for c in characters.split(',') if c.strip()] + char_list = [c.strip() for c in characters.split(",") if c.strip()] if char_list: placeholders = [f":char_{i}" for i in range(len(char_list))] conditions.append(f"character_name IN ({', '.join(placeholders)})") for i, char in enumerate(char_list): params[f"char_{i}"] = char - + # Armor only filter if armor_only: conditions.append("(object_class IN (2, 3))") - + # Build final query where_clause = " AND ".join(conditions) if conditions else "1=1" - + query = f""" SELECT i.id, i.character_name, i.name, i.icon, i.object_class, i.current_wielded_location, @@ -3542,32 +4190,36 @@ async def search_items_by_slot( ORDER BY i.character_name, i.name LIMIT :limit OFFSET :offset """ - - params['limit'] = limit - params['offset'] = offset - + + params["limit"] = limit + params["offset"] = offset + # Execute query rows = await database.fetch_all(query, params) - + # Count total count_query = f"SELECT COUNT(*) as total FROM items i WHERE {where_clause}" - count_params = {k: v for k, v in params.items() if k not in ['limit', 'offset', 'slot_requested']} + count_params = { + k: v + for k, v in params.items() + if k not in ["limit", "offset", "slot_requested"] + } count_result = await database.fetch_one(count_query, count_params) total = count_result[0] if count_result else 0 - + # Process results items = [] for row in rows: items.append(dict(row)) - + return { "items": items, "total_count": total or 0, "slot_searched": slot, "limit": limit, - "offset": offset + "offset": offset, } - + except Exception as e: logger.error(f"Error in slot search: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -3575,31 +4227,38 @@ async def search_items_by_slot( @app.get("/analyze/sets") async def analyze_set_combinations( - characters: str = Query(None, description="Comma-separated list of character names"), + characters: str = Query( + None, description="Comma-separated list of character names" + ), include_all_characters: bool = Query(False, description="Include all characters"), primary_set: int = Query(..., description="Primary set ID (needs 5 pieces)"), secondary_set: int = Query(..., description="Secondary set ID (needs 4 pieces)"), primary_count: int = Query(5, description="Number of primary set pieces needed"), - secondary_count: int = Query(4, description="Number of secondary set pieces needed") + secondary_count: int = Query( + 4, description="Number of secondary set pieces needed" + ), ): """Analyze set combinations for valid 5+4 equipment builds.""" try: # Simplified approach - just count items by set for each character # This uses the same pattern as existing working queries - + if include_all_characters: # Query all characters char_filter = "1=1" query_params = [] elif characters: # Query specific characters - character_list = [c.strip() for c in characters.split(',') if c.strip()] - char_placeholders = ','.join(['%s'] * len(character_list)) + character_list = [c.strip() for c in characters.split(",") if c.strip()] + char_placeholders = ",".join(["%s"] * len(character_list)) char_filter = f"i.character_name IN ({char_placeholders})" query_params = character_list else: - raise HTTPException(status_code=400, detail="Must specify characters or include_all_characters") - + raise HTTPException( + status_code=400, + detail="Must specify characters or include_all_characters", + ) + # Query for primary set primary_query = f""" SELECT @@ -3613,7 +4272,7 @@ async def analyze_set_combinations( AND ird.int_values ? '265' AND (ird.int_values->>'265')::int = :primary_set_id """ - + # Query for secondary set secondary_query = f""" SELECT @@ -3627,7 +4286,7 @@ async def analyze_set_combinations( AND ird.int_values ? '265' AND (ird.int_values->>'265')::int = :secondary_set_id """ - + # Build parameter dictionaries if include_all_characters: primary_params = {"primary_set_id": primary_set} @@ -3635,90 +4294,108 @@ async def analyze_set_combinations( else: # For character filtering, we need to embed character names directly in query # because using named parameters with IN clauses is complex - character_list = [c.strip() for c in characters.split(',') if c.strip()] + character_list = [c.strip() for c in characters.split(",") if c.strip()] char_names = "', '".join(character_list) - - primary_query = primary_query.replace(char_filter, f"i.character_name IN ('{char_names}')") - secondary_query = secondary_query.replace(char_filter, f"i.character_name IN ('{char_names}')") - + + primary_query = primary_query.replace( + char_filter, f"i.character_name IN ('{char_names}')" + ) + secondary_query = secondary_query.replace( + char_filter, f"i.character_name IN ('{char_names}')" + ) + primary_params = {"primary_set_id": primary_set} secondary_params = {"secondary_set_id": secondary_set} - + # Execute queries primary_result = await database.fetch_all(primary_query, primary_params) secondary_result = await database.fetch_all(secondary_query, secondary_params) - + # Process results by character primary_by_char = {} secondary_by_char = {} - + for row in primary_result: - char = row['character_name'] + char = row["character_name"] if char not in primary_by_char: primary_by_char[char] = [] - primary_by_char[char].append({ - 'name': row['name'], - 'equipped': row['current_wielded_location'] > 0 - }) - + primary_by_char[char].append( + {"name": row["name"], "equipped": row["current_wielded_location"] > 0} + ) + for row in secondary_result: - char = row['character_name'] + char = row["character_name"] if char not in secondary_by_char: secondary_by_char[char] = [] - secondary_by_char[char].append({ - 'name': row['name'], - 'equipped': row['current_wielded_location'] > 0 - }) - + secondary_by_char[char].append( + {"name": row["name"], "equipped": row["current_wielded_location"] > 0} + ) + # Analyze combinations analysis_results = [] all_characters = set(primary_by_char.keys()) | set(secondary_by_char.keys()) - + for char in all_characters: primary_items = primary_by_char.get(char, []) secondary_items = secondary_by_char.get(char, []) - + primary_available = len(primary_items) secondary_available = len(secondary_items) - - can_build = (primary_available >= primary_count and - secondary_available >= secondary_count) - - analysis_results.append({ - 'character_name': char, - 'primary_set_available': primary_available, - 'primary_set_needed': primary_count, - 'secondary_set_available': secondary_available, - 'secondary_set_needed': secondary_count, - 'can_build_combination': can_build, - 'primary_items': primary_items[:primary_count] if can_build else primary_items, - 'secondary_items': secondary_items[:secondary_count] if can_build else secondary_items - }) - + + can_build = ( + primary_available >= primary_count + and secondary_available >= secondary_count + ) + + analysis_results.append( + { + "character_name": char, + "primary_set_available": primary_available, + "primary_set_needed": primary_count, + "secondary_set_available": secondary_available, + "secondary_set_needed": secondary_count, + "can_build_combination": can_build, + "primary_items": primary_items[:primary_count] + if can_build + else primary_items, + "secondary_items": secondary_items[:secondary_count] + if can_build + else secondary_items, + } + ) + # Sort by characters who can build first - analysis_results.sort(key=lambda x: (not x['can_build_combination'], x['character_name'])) - + analysis_results.sort( + key=lambda x: (not x["can_build_combination"], x["character_name"]) + ) + # Get set names for response - attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) - primary_set_name = attribute_set_info.get(str(primary_set), f"Set {primary_set}") - secondary_set_name = attribute_set_info.get(str(secondary_set), f"Set {secondary_set}") - + attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get("values", {}) + primary_set_name = attribute_set_info.get( + str(primary_set), f"Set {primary_set}" + ) + secondary_set_name = attribute_set_info.get( + str(secondary_set), f"Set {secondary_set}" + ) + return { - 'primary_set': { - 'id': primary_set, - 'name': primary_set_name, - 'pieces_needed': primary_count + "primary_set": { + "id": primary_set, + "name": primary_set_name, + "pieces_needed": primary_count, }, - 'secondary_set': { - 'id': secondary_set, - 'name': secondary_set_name, - 'pieces_needed': secondary_count + "secondary_set": { + "id": secondary_set, + "name": secondary_set_name, + "pieces_needed": secondary_count, }, - 'character_analysis': analysis_results, - 'total_characters': len(analysis_results), - 'characters_can_build': len([r for r in analysis_results if r['can_build_combination']]) + "character_analysis": analysis_results, + "total_characters": len(analysis_results), + "characters_can_build": len( + [r for r in analysis_results if r["can_build_combination"]] + ), } - + except Exception as e: logger.error(f"Error in set combination analysis: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -3726,13 +4403,19 @@ async def analyze_set_combinations( @app.get("/slots/available") async def get_available_items_by_slot( - characters: str = Query(None, description="Comma-separated list of character names"), + characters: str = Query( + None, description="Comma-separated list of character names" + ), include_all_characters: bool = Query(False, description="Include all characters"), - equipment_sets: str = Query(None, description="Comma-separated equipment set IDs to filter by"), - legendary_cantrips: str = Query(None, description="Comma-separated legendary cantrips to filter by"), + equipment_sets: str = Query( + None, description="Comma-separated equipment set IDs to filter by" + ), + legendary_cantrips: str = Query( + None, description="Comma-separated legendary cantrips to filter by" + ), min_crit_damage_rating: int = Query(None, description="Minimum crit damage rating"), min_damage_rating: int = Query(None, description="Minimum damage rating"), - min_armor_level: int = Query(None, description="Minimum armor level") + min_armor_level: int = Query(None, description="Minimum armor level"), ): """Get available items organized by equipment slot across multiple characters.""" try: @@ -3741,47 +4424,66 @@ async def get_available_items_by_slot( char_filter = "1=1" query_params = {} elif characters: - character_list = [c.strip() for c in characters.split(',') if c.strip()] + character_list = [c.strip() for c in characters.split(",") if c.strip()] char_names = "', '".join(character_list) char_filter = f"i.character_name IN ('{char_names}')" query_params = {} else: - raise HTTPException(status_code=400, detail="Must specify characters or include_all_characters") - + raise HTTPException( + status_code=400, + detail="Must specify characters or include_all_characters", + ) + # Build constraints - only filter if we have specific constraints constraints = [] - if equipment_sets or legendary_cantrips or min_crit_damage_rating or min_damage_rating or min_armor_level: - # Only filter by object class if we have other filters + if ( + equipment_sets + or legendary_cantrips + or min_crit_damage_rating + or min_damage_rating + or min_armor_level + ): + # Only filter by object class if we have other filters constraints.append("i.object_class IN (2, 3, 4)") # Armor and jewelry else: # For general slot queries, include more object classes but focus on equipment - constraints.append("i.object_class IN (1, 2, 3, 4, 6, 7, 8)") # All equipment types - + constraints.append( + "i.object_class IN (1, 2, 3, 4, 6, 7, 8)" + ) # All equipment types + # Equipment set filtering if equipment_sets: - set_ids = [s.strip() for s in equipment_sets.split(',') if s.strip()] - set_filter = " OR ".join([f"(ird.int_values->>'265')::int = {set_id}" for set_id in set_ids]) + set_ids = [s.strip() for s in equipment_sets.split(",") if s.strip()] + set_filter = " OR ".join( + [f"(ird.int_values->>'265')::int = {set_id}" for set_id in set_ids] + ) constraints.append(f"ird.int_values ? '265' AND ({set_filter})") - + # Rating filters using gear totals if min_crit_damage_rating: - constraints.append(f"COALESCE((ird.int_values->>'370')::int, 0) >= {min_crit_damage_rating}") + constraints.append( + f"COALESCE((ird.int_values->>'370')::int, 0) >= {min_crit_damage_rating}" + ) if min_damage_rating: - constraints.append(f"COALESCE((ird.int_values->>'372')::int, 0) >= {min_damage_rating}") + constraints.append( + f"COALESCE((ird.int_values->>'372')::int, 0) >= {min_damage_rating}" + ) if min_armor_level: - constraints.append(f"COALESCE((ird.int_values->>'28')::int, 0) >= {min_armor_level}") - + constraints.append( + f"COALESCE((ird.int_values->>'28')::int, 0) >= {min_armor_level}" + ) + # Build WHERE clause properly where_parts = [char_filter] if constraints: where_parts.extend(constraints) where_clause = " AND ".join(where_parts) - + # Debug: let's see how many items Barris actually has first debug_query = f"SELECT COUNT(*) as total FROM items WHERE {char_filter}" debug_result = await database.fetch_one(debug_query, query_params) logger.debug(f"Total items for query: {debug_result['total']}") - + # Main query to get items with slot information query = f""" SELECT DISTINCT @@ -3824,137 +4526,154 @@ async def get_available_items_by_slot( WHERE {where_clause} ORDER BY i.character_name, i.name """ - + # Cantrip filtering if requested if legendary_cantrips: - cantrip_list = [c.strip() for c in legendary_cantrips.split(',') if c.strip()] + cantrip_list = [ + c.strip() for c in legendary_cantrips.split(",") if c.strip() + ] # Get spell IDs for the requested cantrips cantrip_spell_ids = [] - spells_data = ENUM_MAPPINGS.get('spells', {}).get('values', {}) + spells_data = ENUM_MAPPINGS.get("spells", {}).get("values", {}) for cantrip in cantrip_list: for spell_id, spell_name in spells_data.items(): if cantrip.lower() in spell_name.lower(): cantrip_spell_ids.append(int(spell_id)) - + if cantrip_spell_ids: # Add JOIN to filter by spells - spell_placeholders = ','.join(map(str, cantrip_spell_ids)) - query = query.replace("FROM items i", f""" + spell_placeholders = ",".join(map(str, cantrip_spell_ids)) + query = query.replace( + "FROM items i", + f""" FROM items i INNER JOIN item_spells isp ON i.id = isp.item_id AND isp.spell_id IN ({spell_placeholders}) - """) - + """, + ) + # Execute query rows = await database.fetch_all(query, query_params) - + # Organize items by slot slots_data = {} - + # Define the 9 armor slots we care about armor_slots = { 1: "Head", - 512: "Chest", + 512: "Chest", 2048: "Upper Arms", - 4096: "Lower Arms", + 4096: "Lower Arms", 32: "Hands", 1024: "Abdomen", 8192: "Upper Legs", - 16384: "Lower Legs", - 256: "Feet" + 16384: "Lower Legs", + 256: "Feet", } - + # Jewelry slots jewelry_slots = { "Neck": "Neck", "Left Ring": "Left Ring", - "Right Ring": "Right Ring", + "Right Ring": "Right Ring", "Left Wrist": "Left Wrist", - "Right Wrist": "Right Wrist" + "Right Wrist": "Right Wrist", } - + # Initialize all slots for slot_name in armor_slots.values(): slots_data[slot_name] = [] for slot_name in jewelry_slots.values(): slots_data[slot_name] = [] - + # Process each item for row in rows: item_data = { - "id": row['id'], - "character_name": row['character_name'], - "name": row['name'], - "is_equipped": row['current_wielded_location'] > 0, - "armor_level": row['armor_level'], - "crit_damage_rating": row['crit_damage_rating'], - "damage_rating": row['damage_rating'], - "item_set_id": row['item_set_id'] + "id": row["id"], + "character_name": row["character_name"], + "name": row["name"], + "is_equipped": row["current_wielded_location"] > 0, + "armor_level": row["armor_level"], + "crit_damage_rating": row["crit_damage_rating"], + "damage_rating": row["damage_rating"], + "item_set_id": row["item_set_id"], } - + # Add material name if available - if row['material_type']: - material_name = ENUM_MAPPINGS.get('materials', {}).get(str(row['material_type']), '') - if material_name and not row['name'].lower().startswith(material_name.lower()): - item_data['name'] = f"{material_name} {row['name']}" - + if row["material_type"]: + material_name = ENUM_MAPPINGS.get("materials", {}).get( + str(row["material_type"]), "" + ) + if material_name and not row["name"].lower().startswith( + material_name.lower() + ): + item_data["name"] = f"{material_name} {row['name']}" + # Add set name if available - if row['item_set_id']: - attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) - set_name = attribute_set_info.get(str(row['item_set_id']), '') + if row["item_set_id"]: + attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get( + "values", {} + ) + set_name = attribute_set_info.get(str(row["item_set_id"]), "") if set_name: - item_data['set_name'] = set_name - + item_data["set_name"] = set_name + # Use the same slot computation logic as the search endpoint - equippable_slots = row['valid_locations'] - coverage_value = row['coverage_mask'] + equippable_slots = row["valid_locations"] + coverage_value = row["coverage_mask"] slot_names = [] - + if equippable_slots and equippable_slots > 0: # Get sophisticated slot options (handles armor reduction) - has_material = row['material_type'] is not None - slot_options = get_sophisticated_slot_options(equippable_slots, coverage_value, has_material) - + has_material = row["material_type"] is not None + slot_options = get_sophisticated_slot_options( + equippable_slots, coverage_value, has_material + ) + # Convert slot options to friendly slot names for slot_option in slot_options: slot_name = translate_equipment_slot(slot_option) if slot_name and slot_name not in slot_names: slot_names.append(slot_name) - + # Add item to each computed slot for slot_name in slot_names: if slot_name in slots_data: slots_data[slot_name].append(item_data.copy()) - + # Handle jewelry separately if armor logic didn't work - if row['object_class'] == 4 and not slot_names: # Jewelry - item_name = row['name'].lower() - - if 'ring' in item_name: + if row["object_class"] == 4 and not slot_names: # Jewelry + item_name = row["name"].lower() + + if "ring" in item_name: slots_data["Left Ring"].append(item_data.copy()) slots_data["Right Ring"].append(item_data.copy()) - elif any(word in item_name for word in ['bracelet', 'wrist']): + elif any(word in item_name for word in ["bracelet", "wrist"]): slots_data["Left Wrist"].append(item_data.copy()) slots_data["Right Wrist"].append(item_data.copy()) - elif any(word in item_name for word in ['necklace', 'amulet', 'gorget']): + elif any( + word in item_name for word in ["necklace", "amulet", "gorget"] + ): slots_data["Neck"].append(item_data.copy()) - + # Sort items within each slot by character name, then by name for slot in slots_data: - slots_data[slot].sort(key=lambda x: (x['character_name'], x['name'])) - + slots_data[slot].sort(key=lambda x: (x["character_name"], x["name"])) + return { "slots": slots_data, "total_items": sum(len(items) for items in slots_data.values()), "constraints_applied": { - "equipment_sets": equipment_sets.split(',') if equipment_sets else None, - "legendary_cantrips": legendary_cantrips.split(',') if legendary_cantrips else None, + "equipment_sets": equipment_sets.split(",") if equipment_sets else None, + "legendary_cantrips": legendary_cantrips.split(",") + if legendary_cantrips + else None, "min_crit_damage_rating": min_crit_damage_rating, "min_damage_rating": min_damage_rating, - "min_armor_level": min_armor_level - } + "min_armor_level": min_armor_level, + }, } - + except Exception as e: logger.error(f"Error in slots/available endpoint: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -3963,17 +4682,26 @@ async def get_available_items_by_slot( @app.post("/optimize/suits") async def optimize_suits( # Character selection - characters: str = Query(None, description="Comma-separated list of character names"), - include_all_characters: bool = Query(False, description="Search across all characters"), - + characters: str = Query( + None, description="Comma-separated list of character names" + ), + include_all_characters: bool = Query( + False, description="Search across all characters" + ), # Equipment sets (primary/secondary requirements) - primary_set: str = Query(None, description="Primary equipment set ID (requires 5 pieces)"), - secondary_set: str = Query(None, description="Secondary equipment set ID (requires 4 pieces)"), - + primary_set: str = Query( + None, description="Primary equipment set ID (requires 5 pieces)" + ), + secondary_set: str = Query( + None, description="Secondary equipment set ID (requires 4 pieces)" + ), # Spell requirements - legendary_cantrips: str = Query(None, description="Comma-separated list of required legendary cantrips"), - legendary_wards: str = Query(None, description="Comma-separated list of required legendary wards"), - + legendary_cantrips: str = Query( + None, description="Comma-separated list of required legendary cantrips" + ), + legendary_wards: str = Query( + None, description="Comma-separated list of required legendary wards" + ), # Rating requirements min_armor: int = Query(None, description="Minimum total armor level"), max_armor: int = Query(None, description="Maximum total armor level"), @@ -3981,20 +4709,21 @@ async def optimize_suits( max_crit_damage: int = Query(None, description="Maximum total crit damage rating"), min_damage_rating: int = Query(None, description="Minimum total damage rating"), max_damage_rating: int = Query(None, description="Maximum total damage rating"), - # Equipment status include_equipped: bool = Query(True, description="Include equipped items"), include_inventory: bool = Query(True, description="Include inventory items"), - # Locked slots (exclude from optimization) - locked_slots: str = Query(None, description="Comma-separated list of locked slot names"), - + locked_slots: str = Query( + None, description="Comma-separated list of locked slot names" + ), # Result options - max_results: int = Query(10, ge=1, le=50, description="Maximum number of suit results to return") + max_results: int = Query( + 10, ge=1, le=50, description="Maximum number of suit results to return" + ), ): """ MagSuitbuilder-inspired constraint solver for optimal equipment combinations. - + Uses two-phase algorithm: ArmorSearcher with strict set filtering, then AccessorySearcher for spells. """ try: @@ -4005,17 +4734,13 @@ async def optimize_suits( query = "SELECT DISTINCT character_name FROM items ORDER BY character_name" async with database.transaction(): rows = await database.fetch_all(query) - char_list = [row['character_name'] for row in rows] + char_list = [row["character_name"] for row in rows] elif characters: - char_list = [c.strip() for c in characters.split(',') if c.strip()] - + char_list = [c.strip() for c in characters.split(",") if c.strip()] + if not char_list: - return { - "suits": [], - "message": "No characters specified", - "total_found": 0 - } - + return {"suits": [], "message": "No characters specified", "total_found": 0} + # Determine equipment status filtering logic equipment_status_filter = None if include_equipped and include_inventory: @@ -4028,28 +4753,36 @@ async def optimize_suits( return { "suits": [], "message": "Must include either equipped or inventory items", - "total_found": 0 + "total_found": 0, } - + # Build constraints dictionary constraints = { - 'primary_set': int(primary_set) if primary_set else None, - 'secondary_set': int(secondary_set) if secondary_set else None, - 'min_armor': min_armor or 0, - 'min_crit_damage': min_crit_damage or 0, - 'min_damage_rating': min_damage_rating or 0, - 'min_heal_boost': 0, - 'legendary_cantrips': [c.strip() for c in legendary_cantrips.split(',') if c.strip()] if legendary_cantrips else [], - 'protection_spells': [w.strip() for w in legendary_wards.split(',') if w.strip()] if legendary_wards else [], - 'equipment_status_filter': equipment_status_filter + "primary_set": int(primary_set) if primary_set else None, + "secondary_set": int(secondary_set) if secondary_set else None, + "min_armor": min_armor or 0, + "min_crit_damage": min_crit_damage or 0, + "min_damage_rating": min_damage_rating or 0, + "min_heal_boost": 0, + "legendary_cantrips": [ + c.strip() for c in legendary_cantrips.split(",") if c.strip() + ] + if legendary_cantrips + else [], + "protection_spells": [ + w.strip() for w in legendary_wards.split(",") if w.strip() + ] + if legendary_wards + else [], + "equipment_status_filter": equipment_status_filter, } - + # Initialize MagSuitbuilder-inspired solver solver = ConstraintSatisfactionSolver(char_list, constraints) - + # Execute two-phase search algorithm results = await solver.find_optimal_suits() - + return { "suits": results["suits"], "total_found": results["total_found"], @@ -4057,19 +4790,21 @@ async def optimize_suits( "accessory_items_available": results.get("accessory_items_available", 0), "message": results.get("message", "Search completed successfully"), "constraints": constraints, - "characters_searched": char_list + "characters_searched": char_list, } - + except Exception as e: logger.error(f"Error in optimize/suits endpoint: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/debug/available-sets") -async def get_available_sets(characters: str = Query(..., description="Comma-separated character names")): +async def get_available_sets( + characters: str = Query(..., description="Comma-separated character names"), +): """Debug endpoint to see what equipment sets are available""" - character_list = [c.strip() for c in characters.split(',') if c.strip()] - + character_list = [c.strip() for c in characters.split(",") if c.strip()] + query = """ SELECT DISTINCT enh.item_set, COUNT(*) as item_count FROM items i @@ -4081,69 +4816,90 @@ async def get_available_sets(characters: str = Query(..., description="Comma-sep GROUP BY enh.item_set ORDER BY item_count DESC """ - + async with database.transaction(): rows = await database.fetch_all(query, {"characters": character_list}) - - return {"available_sets": [{"set_id": row["item_set"], "armor_count": row["item_count"]} for row in rows]} + + return { + "available_sets": [ + {"set_id": row["item_set"], "armor_count": row["item_count"]} + for row in rows + ] + } + @app.get("/debug/test-simple-search") -async def test_simple_search(characters: str = Query(..., description="Comma-separated character names")): +async def test_simple_search( + characters: str = Query(..., description="Comma-separated character names"), +): """Test endpoint to find suits with NO constraints""" - character_list = [c.strip() for c in characters.split(',') if c.strip()] - + character_list = [c.strip() for c in characters.split(",") if c.strip()] + # Create minimal constraints (no set requirements, no cantrip requirements) constraints = { - 'primary_set': None, - 'secondary_set': None, - 'min_armor': 0, - 'min_crit_damage': 0, - 'min_damage_rating': 0, - 'min_heal_boost': 0, - 'legendary_cantrips': [], - 'protection_spells': [], - 'equipment_status_filter': 'both' + "primary_set": None, + "secondary_set": None, + "min_armor": 0, + "min_crit_damage": 0, + "min_damage_rating": 0, + "min_heal_boost": 0, + "legendary_cantrips": [], + "protection_spells": [], + "equipment_status_filter": "both", } - + try: solver = ConstraintSatisfactionSolver(character_list, constraints) result = await solver.find_optimal_suits() - + return { "message": "Simple search (no constraints) completed", "suits_found": result.get("total_found", 0), "armor_items_available": result.get("armor_items_available", 0), "accessory_items_available": result.get("accessory_items_available", 0), - "sample_suits": result.get("suits", [])[:3] # First 3 suits + "sample_suits": result.get("suits", [])[:3], # First 3 suits } except Exception as e: logger.error(f"Error in simple search: {e}") return {"error": str(e)} + @app.get("/optimize/suits/stream") async def stream_optimize_suits( request: Request, # Add request to detect client disconnection characters: str = Query(..., description="Comma-separated character names"), primary_set: Optional[int] = Query(None, description="Primary set ID requirement"), - secondary_set: Optional[int] = Query(None, description="Secondary set ID requirement"), + secondary_set: Optional[int] = Query( + None, description="Secondary set ID requirement" + ), min_armor: Optional[int] = Query(None, description="Minimum armor requirement"), - min_crit_damage: Optional[int] = Query(None, description="Minimum crit damage requirement"), - min_damage_rating: Optional[int] = Query(None, description="Minimum damage rating requirement"), - legendary_cantrips: Optional[str] = Query(None, description="Comma-separated legendary cantrips"), - legendary_wards: Optional[str] = Query(None, description="Comma-separated protection spells"), + min_crit_damage: Optional[int] = Query( + None, description="Minimum crit damage requirement" + ), + min_damage_rating: Optional[int] = Query( + None, description="Minimum damage rating requirement" + ), + legendary_cantrips: Optional[str] = Query( + None, description="Comma-separated legendary cantrips" + ), + legendary_wards: Optional[str] = Query( + None, description="Comma-separated protection spells" + ), include_equipped: bool = Query(True, description="Include equipped items"), include_inventory: bool = Query(True, description="Include inventory items"), - search_depth: str = Query("balanced", description="Search depth: quick, balanced, deep, exhaustive") + search_depth: str = Query( + "balanced", description="Search depth: quick, balanced, deep, exhaustive" + ), ): """Stream suit optimization results progressively using Server-Sent Events""" - + # Validate input if not characters: raise HTTPException(status_code=400, detail="No characters specified") - + # Split character names - character_list = [c.strip() for c in characters.split(',') if c.strip()] - + character_list = [c.strip() for c in characters.split(",") if c.strip()] + # Determine equipment status filter if include_equipped and include_inventory: equipment_status_filter = "both" @@ -4152,40 +4908,50 @@ async def stream_optimize_suits( elif include_inventory: equipment_status_filter = "inventory_only" else: - raise HTTPException(status_code=400, detail="Must include either equipped or inventory items") - + raise HTTPException( + status_code=400, detail="Must include either equipped or inventory items" + ) + # Build constraints constraints = { - 'primary_set': primary_set, - 'secondary_set': secondary_set, - 'min_armor': min_armor or 0, - 'min_crit_damage': min_crit_damage or 0, - 'min_damage_rating': min_damage_rating or 0, - 'min_heal_boost': 0, - 'legendary_cantrips': [c.strip() for c in legendary_cantrips.split(',') if c.strip()] if legendary_cantrips else [], - 'protection_spells': [w.strip() for w in legendary_wards.split(',') if w.strip()] if legendary_wards else [], - 'equipment_status_filter': equipment_status_filter + "primary_set": primary_set, + "secondary_set": secondary_set, + "min_armor": min_armor or 0, + "min_crit_damage": min_crit_damage or 0, + "min_damage_rating": min_damage_rating or 0, + "min_heal_boost": 0, + "legendary_cantrips": [ + c.strip() for c in legendary_cantrips.split(",") if c.strip() + ] + if legendary_cantrips + else [], + "protection_spells": [ + w.strip() for w in legendary_wards.split(",") if w.strip() + ] + if legendary_wards + else [], + "equipment_status_filter": equipment_status_filter, } - + async def generate_suits(): """Generator that yields suits progressively""" solver = ConstraintSatisfactionSolver(character_list, constraints) - + # Configure search depth search_limits = { "quick": {"max_combinations": 10, "time_limit": 2}, "balanced": {"max_combinations": 50, "time_limit": 10}, "deep": {"max_combinations": 200, "time_limit": 30}, - "exhaustive": {"max_combinations": 1000, "time_limit": 120} + "exhaustive": {"max_combinations": 1000, "time_limit": 120}, } - + limit_config = search_limits.get(search_depth, search_limits["balanced"]) solver.max_combinations = limit_config["max_combinations"] - + # Start search start_time = time.time() found_count = 0 - + try: # Phase 1: Get armor items logger.info(f"Starting search with constraints: {constraints}") @@ -4195,71 +4961,84 @@ async def stream_optimize_suits( logger.warning("No armor items found matching set requirements") yield { "event": "error", - "data": json.dumps({"message": "No armor items found matching set requirements"}) + "data": json.dumps( + {"message": "No armor items found matching set requirements"} + ), } return - + # Phase 2: Get accessories accessory_items = await solver._get_accessory_items() logger.info(f"Found {len(accessory_items)} accessory items") - + # Yield initial status yield { "event": "status", - "data": json.dumps({ - "armor_items": len(armor_items), - "accessory_items": len(accessory_items), - "search_depth": search_depth - }) + "data": json.dumps( + { + "armor_items": len(armor_items), + "accessory_items": len(accessory_items), + "search_depth": search_depth, + } + ), } - + # Phase 3: Generate combinations progressively armor_combinations = solver._generate_armor_combinations(armor_items) logger.info(f"Generated {len(armor_combinations)} armor combinations") - + for i, armor_combo in enumerate(armor_combinations): # Check for client disconnection if await request.is_disconnected(): logger.info("Client disconnected, stopping search") yield { "event": "cancelled", - "data": json.dumps({"message": "Search cancelled by client"}) + "data": json.dumps({"message": "Search cancelled by client"}), } return - + # Check time limit if time.time() - start_time > limit_config["time_limit"]: yield { "event": "timeout", - "data": json.dumps({"message": f"Search time limit reached ({limit_config['time_limit']}s)"}) + "data": json.dumps( + { + "message": f"Search time limit reached ({limit_config['time_limit']}s)" + } + ), } break - + # Complete suit with accessories - complete_suit = solver._complete_suit_with_accessories(armor_combo, accessory_items) + complete_suit = solver._complete_suit_with_accessories( + armor_combo, accessory_items + ) if complete_suit: # Score the suit scored_suits = solver._score_suits([complete_suit]) if scored_suits: - logger.info(f"Combination {i}: scored {scored_suits[0]['score']}") + logger.info( + f"Combination {i}: scored {scored_suits[0]['score']}" + ) if scored_suits[0]["score"] > 0: found_count += 1 - + # Yield the suit - yield { - "event": "suit", - "data": json.dumps(scored_suits[0]) - } - + yield {"event": "suit", "data": json.dumps(scored_suits[0])} + # Yield progress update if found_count % 5 == 0: yield { - "event": "progress", - "data": json.dumps({ - "found": found_count, - "checked": i + 1, - "elapsed": round(time.time() - start_time, 1) - }) + "event": "progress", + "data": json.dumps( + { + "found": found_count, + "checked": i + 1, + "elapsed": round( + time.time() - start_time, 1 + ), + } + ), } else: logger.info(f"Combination {i}: suit scored 0, skipping") @@ -4267,33 +5046,32 @@ async def stream_optimize_suits( logger.info(f"Combination {i}: no scored suits returned") else: logger.info(f"Combination {i}: no complete suit generated") - + # Also check for disconnection every 10 iterations if i % 10 == 0 and await request.is_disconnected(): logger.info("Client disconnected during search") yield { "event": "cancelled", - "data": json.dumps({"message": "Search cancelled by client"}) + "data": json.dumps({"message": "Search cancelled by client"}), } return - + # Final status yield { "event": "complete", - "data": json.dumps({ - "total_found": found_count, - "combinations_checked": len(armor_combinations), - "total_time": round(time.time() - start_time, 1) - }) + "data": json.dumps( + { + "total_found": found_count, + "combinations_checked": len(armor_combinations), + "total_time": round(time.time() - start_time, 1), + } + ), } - + except Exception as e: logger.error(f"Error in streaming search: {e}") - yield { - "event": "error", - "data": json.dumps({"message": str(e)}) - } - + yield {"event": "error", "data": json.dumps({"message": str(e)})} + return EventSourceResponse(generate_suits()) @@ -4301,22 +5079,38 @@ async def organize_items_by_slot(rows, locked_slots_str=None): """ Organize items by their possible equipment slots. """ - locked_slots = set(locked_slots_str.split(',')) if locked_slots_str else set() - + locked_slots = set(locked_slots_str.split(",")) if locked_slots_str else set() + # Define all possible equipment slots - armor_slots = ["Head", "Chest", "Upper Arms", "Lower Arms", "Hands", - "Abdomen", "Upper Legs", "Lower Legs", "Feet"] - jewelry_slots = ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"] + armor_slots = [ + "Head", + "Chest", + "Upper Arms", + "Lower Arms", + "Hands", + "Abdomen", + "Upper Legs", + "Lower Legs", + "Feet", + ] + jewelry_slots = [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ] clothing_slots = ["Shirt", "Pants"] - + all_slots = armor_slots + jewelry_slots + clothing_slots - + # Initialize slot dictionary items_by_slot = {slot: [] for slot in all_slots if slot not in locked_slots} - + # Debug: Track slot assignment for troubleshooting debug_slot_assignments = {} - + for row in rows: # Convert row to item dict item = { @@ -4332,29 +5126,29 @@ async def organize_items_by_slot(rows, locked_slots_str=None): "item_set_id": row["item_set_id"], "spell_names": row["spell_names"] or [], "valid_locations": row["valid_locations"], - "coverage_mask": row["coverage_mask"] + "coverage_mask": row["coverage_mask"], } - + # Determine which slots this item can go in possible_slots = determine_item_slots(item) - + # Debug: Log slot assignment debug_slot_assignments[item["name"]] = { - "coverage_mask": row["coverage_mask"], + "coverage_mask": row["coverage_mask"], "possible_slots": possible_slots, "is_equipped": item["is_equipped"], - "item_set_id": row["item_set_id"] + "item_set_id": row["item_set_id"], } - + # Add item to each possible slot (excluding locked slots) for slot in possible_slots: if slot in items_by_slot: # Skip locked slots items_by_slot[slot].append(item.copy()) - + # Debug: Log slot assignment summary for Barris if any("Barris" in row["character_name"] for row in rows): logger.info(f"DEBUG: Barris slot assignments: {debug_slot_assignments}") - + return items_by_slot @@ -4364,40 +5158,40 @@ def decode_valid_locations_to_jewelry_slots(valid_locations): Based on EquipMask enum from Mag-Plugins. """ slots = [] - + # Jewelry slot mappings (EquipMask values) jewelry_location_map = { - 0x00000001: "Head", # HeadWear - 0x00000008: "Neck", # Necklace - 0x00000010: "Chest", # ChestArmor - 0x00000080: "Abdomen", # AbdomenArmor - 0x00000020: "Upper Arms", # UpperArmArmor - 0x00000040: "Lower Arms", # LowerArmArmor - 0x00000002: "Hands", # HandWear - 0x00000100: "Left Ring", # LeftFinger - 0x00000200: "Right Ring", # RightFinger - 0x00000400: "Left Wrist", # LeftWrist - 0x00000800: "Right Wrist", # RightWrist - 0x00000004: "Feet", # FootWear - 0x00001000: "Upper Legs", # UpperLegArmor - 0x00002000: "Lower Legs", # LowerLegArmor - 0x00008000: "Trinket" # TrinketOne + 0x00000001: "Head", # HeadWear + 0x00000008: "Neck", # Necklace + 0x00000010: "Chest", # ChestArmor + 0x00000080: "Abdomen", # AbdomenArmor + 0x00000020: "Upper Arms", # UpperArmArmor + 0x00000040: "Lower Arms", # LowerArmArmor + 0x00000002: "Hands", # HandWear + 0x00000100: "Left Ring", # LeftFinger + 0x00000200: "Right Ring", # RightFinger + 0x00000400: "Left Wrist", # LeftWrist + 0x00000800: "Right Wrist", # RightWrist + 0x00000004: "Feet", # FootWear + 0x00001000: "Upper Legs", # UpperLegArmor + 0x00002000: "Lower Legs", # LowerLegArmor + 0x00008000: "Trinket", # TrinketOne } - + # Check each jewelry-relevant bit jewelry_bits = { - 0x00000008: "Neck", # Necklace - 0x00000100: "Left Ring", # LeftFinger - 0x00000200: "Right Ring", # RightFinger - 0x00000400: "Left Wrist", # LeftWrist - 0x00000800: "Right Wrist", # RightWrist - 0x00008000: "Trinket" # TrinketOne + 0x00000008: "Neck", # Necklace + 0x00000100: "Left Ring", # LeftFinger + 0x00000200: "Right Ring", # RightFinger + 0x00000400: "Left Wrist", # LeftWrist + 0x00000800: "Right Wrist", # RightWrist + 0x00008000: "Trinket", # TrinketOne } - + for bit_value, slot_name in jewelry_bits.items(): if valid_locations & bit_value: slots.append(slot_name) - + return slots @@ -4406,7 +5200,7 @@ def detect_jewelry_slots_by_name(item_name): Fallback jewelry slot detection based on item name patterns. """ slots = [] - + if "ring" in item_name: slots.extend(["Left Ring", "Right Ring"]) elif any(word in item_name for word in ["bracelet", "wrist"]): @@ -4418,7 +5212,7 @@ def detect_jewelry_slots_by_name(item_name): else: # Default jewelry fallback slots.append("Trinket") - + return slots @@ -4427,12 +5221,12 @@ def determine_item_slots(item): Determine which equipment slots an item can be equipped to. """ slots = [] - + # Handle jewelry by ValidLocations and name patterns if item["object_class"] == 4: # Jewelry valid_locations = item.get("valid_locations", 0) item_name = item["name"].lower() - + # Use ValidLocations bitmask if available (more accurate) if valid_locations and valid_locations > 0: jewelry_slots = decode_valid_locations_to_jewelry_slots(valid_locations) @@ -4444,35 +5238,45 @@ def determine_item_slots(item): else: # Fallback to name-based detection slots.extend(detect_jewelry_slots_by_name(item_name)) - + # Handle armor/clothing by coverage mask or name patterns elif item["object_class"] in [2, 3]: # Armor (2) or Clothing (3) coverage = item.get("coverage_mask", item.get("coverage", 0)) item_name = item["name"].lower() - + # Name-based detection for clothing items first (for ObjectClass 3) if item["object_class"] == 3: if any(word in item_name for word in ["pants", "breeches", "baggy"]): slots.append("Pants") return slots elif any(word in item_name for word in ["shirt", "tunic"]): - slots.append("Shirt") + slots.append("Shirt") return slots - + # Use coverage mask if available if coverage and coverage > 0: - slots.extend(decode_coverage_to_slots(coverage, item["object_class"], item["name"])) + slots.extend( + decode_coverage_to_slots(coverage, item["object_class"], item["name"]) + ) else: # Fallback to name-based detection - if any(word in item_name for word in ["helm", "cap", "hat", "circlet", "crown"]): + if any( + word in item_name for word in ["helm", "cap", "hat", "circlet", "crown"] + ): slots.append("Head") elif any(word in item_name for word in ["robe", "pallium", "robes"]): slots.append("Robe") # Robes have their own dedicated slot elif any(word in item_name for word in ["chest", "cuirass", "hauberk"]): slots.append("Chest") - elif item["object_class"] == 3: # Handle ObjectClass 3 items (clothing and robes) + elif ( + item["object_class"] == 3 + ): # Handle ObjectClass 3 items (clothing and robes) # Use coverage mask detection for all ObjectClass 3 items - slots.extend(decode_coverage_to_slots(coverage, item["object_class"], item["name"])) + slots.extend( + decode_coverage_to_slots( + coverage, item["object_class"], item["name"] + ) + ) elif any(word in item_name for word in ["gauntlet", "glove"]): slots.append("Hands") elif any(word in item_name for word in ["boot", "shoe", "slipper"]): @@ -4486,17 +5290,17 @@ def determine_item_slots(item): slots.append("Upper Arms") elif "cloak" in item_name: slots.append("Cloak") # Cloaks have their own dedicated slot - + return slots if slots else ["Trinket"] # Default fallback -def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): +def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=""): """ Convert coverage mask to equipment slot names, including clothing. Only classify as clothing if ObjectClass is 3 (Clothing). """ slots = [] - + # Only check for clothing patterns if this is actually a clothing item (ObjectClass 3) if object_class == 3: # Check coverage mask patterns for underclothes first @@ -4504,89 +5308,100 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): if coverage_mask & 24: # 8 + 16 = 24 slots.append("Shirt") return slots - + # Pants: UnderwearUpperLegs (2) AND UnderwearLowerLegs (4) = mask & 6 = 6 if (coverage_mask & 6) == 6: # Both bits 2 and 4 must be set slots.append("Pants") return slots # Check for clothing patterns based on actual inventory data # Specific coverage patterns for ObjectClass 3 clothing items: - + # Shirt pattern: OuterwearChest + OuterwearUpperArms + OuterwearLowerArms = 1024 + 4096 + 8192 = 13312 - shirt_pattern = (coverage_mask & 1024) and (coverage_mask & 4096) and (coverage_mask & 8192) + shirt_pattern = ( + (coverage_mask & 1024) and (coverage_mask & 4096) and (coverage_mask & 8192) + ) if shirt_pattern: slots.append("Shirt") return slots # Return early for clothing to avoid adding armor slots - - # Pants pattern: OuterwearUpperLegs + OuterwearLowerLegs + OuterwearAbdomen = 256 + 512 + 2048 = 2816 - pants_pattern = (coverage_mask & 256) and (coverage_mask & 512) and (coverage_mask & 2048) + + # Pants pattern: OuterwearUpperLegs + OuterwearLowerLegs + OuterwearAbdomen = 256 + 512 + 2048 = 2816 + pants_pattern = ( + (coverage_mask & 256) and (coverage_mask & 512) and (coverage_mask & 2048) + ) if pants_pattern: slots.append("Pants") return slots # Return early for clothing to avoid adding armor slots - + # Check for underwear patterns (theoretical) # Shirt = UnderwearChest (8) + UnderwearAbdomen (16) = 24 - if coverage_mask & 8 and coverage_mask & 16: # UnderwearChest + UnderwearAbdomen + if ( + coverage_mask & 8 and coverage_mask & 16 + ): # UnderwearChest + UnderwearAbdomen slots.append("Shirt") return slots - + # Pants = UnderwearUpperLegs (2) + UnderwearLowerLegs (4) + UnderwearAbdomen (16) = 22 - if coverage_mask & 2 and coverage_mask & 4 and coverage_mask & 16: # Full pants pattern + if ( + coverage_mask & 2 and coverage_mask & 4 and coverage_mask & 16 + ): # Full pants pattern slots.append("Pants") return slots # Also check for simple pants pattern without abdomen - elif coverage_mask & 2 and coverage_mask & 4: # UnderwearUpperLegs + UnderwearLowerLegs + elif ( + coverage_mask & 2 and coverage_mask & 4 + ): # UnderwearUpperLegs + UnderwearLowerLegs slots.append("Pants") return slots - + # Cloak = 131072 - Exclude cloaks from suit building if coverage_mask & 131072: slots.append("Cloak") # Cloaks have their own dedicated slot return slots - + # Robe detection - Check for robe patterns that might differ from shirt patterns # If an item has chest coverage but is ObjectClass 3 and doesn't match shirt patterns, # and has name indicators, classify as robe - if (coverage_mask & 1024) and any(word in item_name.lower() for word in ['robe', 'pallium']): + if (coverage_mask & 1024) and any( + word in item_name.lower() for word in ["robe", "pallium"] + ): slots.append("Robe") # Robes have their own dedicated slot return slots - + # Armor coverage bit mappings armor_coverage_map = { 1: "Head", - 256: "Upper Legs", # OuterwearUpperLegs - 512: "Lower Legs", # OuterwearLowerLegs - 1024: "Chest", # OuterwearChest - 2048: "Abdomen", # OuterwearAbdomen - 4096: "Upper Arms", # OuterwearUpperArms - 8192: "Lower Arms", # OuterwearLowerArms - 16384: "Head", # Head - 32768: "Hands", # Hands - 65536: "Feet", # Feet + 256: "Upper Legs", # OuterwearUpperLegs + 512: "Lower Legs", # OuterwearLowerLegs + 1024: "Chest", # OuterwearChest + 2048: "Abdomen", # OuterwearAbdomen + 4096: "Upper Arms", # OuterwearUpperArms + 8192: "Lower Arms", # OuterwearLowerArms + 16384: "Head", # Head + 32768: "Hands", # Hands + 65536: "Feet", # Feet } - + # Jewelry coverage bit mappings jewelry_coverage_map = { - 262144: "Neck", # Necklace coverage - 524288: "Left Ring", # Ring coverage - 1048576: "Right Ring", # Ring coverage + 262144: "Neck", # Necklace coverage + 524288: "Left Ring", # Ring coverage + 1048576: "Right Ring", # Ring coverage 2097152: "Left Wrist", # Wrist coverage - 4194304: "Right Wrist", # Wrist coverage - 8388608: "Trinket" # Trinket coverage + 4194304: "Right Wrist", # Wrist coverage + 8388608: "Trinket", # Trinket coverage } - + # Check armor coverage bits for bit_value, slot_name in armor_coverage_map.items(): if coverage_mask & bit_value: slots.append(slot_name) - - # Check jewelry coverage bits + + # Check jewelry coverage bits for bit_value, slot_name in jewelry_coverage_map.items(): if coverage_mask & bit_value: slots.append(slot_name) - - return list(set(slots)) # Remove duplicates + return list(set(slots)) # Remove duplicates def categorize_items_by_set(items): @@ -4604,63 +5419,96 @@ def categorize_items_by_set(items): def categorize_items_by_spell(items, required_spells): """Categorize items by spells for efficient spell-based optimization.""" items_by_spell = {spell: [] for spell in required_spells} - + for item in items: item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: items_by_spell[spell].append(item) - + return items_by_spell -def build_suit_set_priority(items_by_set, items_by_spell, items_by_slot, - primary_set, secondary_set, required_spells): +def build_suit_set_priority( + items_by_set, + items_by_spell, + items_by_slot, + primary_set, + secondary_set, + required_spells, +): """Build suit prioritizing equipment set requirements first.""" suit = { "items": {}, - "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, + "stats": { + "primary_set_count": 0, + "secondary_set_count": 0, + "required_spells_found": 0, + }, "missing": [], - "notes": [] + "notes": [], } used_items = set() - + # Priority 1: Place primary set items if primary_set and int(primary_set) in items_by_set: - primary_items = sorted(items_by_set[int(primary_set)], - key=lambda x: x.get("armor_level", 0), reverse=True) - placed = place_set_items_optimally(suit, primary_items, 5, used_items, items_by_slot) + primary_items = sorted( + items_by_set[int(primary_set)], + key=lambda x: x.get("armor_level", 0), + reverse=True, + ) + placed = place_set_items_optimally( + suit, primary_items, 5, used_items, items_by_slot + ) suit["stats"]["primary_set_count"] = placed - + # Priority 2: Place secondary set items if secondary_set and int(secondary_set) in items_by_set: - secondary_items = sorted(items_by_set[int(secondary_set)], - key=lambda x: x.get("armor_level", 0), reverse=True) - placed = place_set_items_optimally(suit, secondary_items, 4, used_items, items_by_slot) + secondary_items = sorted( + items_by_set[int(secondary_set)], + key=lambda x: x.get("armor_level", 0), + reverse=True, + ) + placed = place_set_items_optimally( + suit, secondary_items, 4, used_items, items_by_slot + ) suit["stats"]["secondary_set_count"] = placed - + # Priority 3: Fill remaining slots with best available items fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells) - + return suit -def build_suit_spell_priority(items_by_set, items_by_spell, items_by_slot, - primary_set, secondary_set, required_spells): +def build_suit_spell_priority( + items_by_set, + items_by_spell, + items_by_slot, + primary_set, + secondary_set, + required_spells, +): """Build suit prioritizing spell requirements first.""" suit = { "items": {}, - "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, + "stats": { + "primary_set_count": 0, + "secondary_set_count": 0, + "required_spells_found": 0, + }, "missing": [], - "notes": [] + "notes": [], } used_items = set() - + # Priority 1: Place items with required spells for spell in required_spells: if spell in items_by_spell and items_by_spell[spell]: - spell_items = sorted(items_by_spell[spell], - key=lambda x: x.get("armor_level", 0), reverse=True) + spell_items = sorted( + items_by_spell[spell], + key=lambda x: x.get("armor_level", 0), + reverse=True, + ) for item in spell_items[:2]: # Limit to prevent spell hogging if item["item_id"] not in used_items: slots = determine_item_slots(item) @@ -4670,107 +5518,141 @@ def build_suit_spell_priority(items_by_set, items_by_spell, items_by_slot, used_items.add(item["item_id"]) suit["stats"]["required_spells_found"] += 1 break - + # Priority 2: Add set items to remaining slots if primary_set and int(primary_set) in items_by_set: primary_items = items_by_set[int(primary_set)] - placed = place_set_items_optimally(suit, primary_items, 5, used_items, items_by_slot, replace_ok=False) + placed = place_set_items_optimally( + suit, primary_items, 5, used_items, items_by_slot, replace_ok=False + ) suit["stats"]["primary_set_count"] = placed - + if secondary_set and int(secondary_set) in items_by_set: secondary_items = items_by_set[int(secondary_set)] - placed = place_set_items_optimally(suit, secondary_items, 4, used_items, items_by_slot, replace_ok=False) + placed = place_set_items_optimally( + suit, secondary_items, 4, used_items, items_by_slot, replace_ok=False + ) suit["stats"]["secondary_set_count"] = placed - + # Priority 3: Fill remaining slots fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells) - + return suit -def build_suit_balanced(items_by_set, items_by_spell, items_by_slot, - primary_set, secondary_set, required_spells): +def build_suit_balanced( + items_by_set, + items_by_spell, + items_by_slot, + primary_set, + secondary_set, + required_spells, +): """Build suit using balanced approach between sets and spells.""" suit = { "items": {}, - "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, + "stats": { + "primary_set_count": 0, + "secondary_set_count": 0, + "required_spells_found": 0, + }, "missing": [], - "notes": [] + "notes": [], } used_items = set() - + # Interleave set and spell placement - armor_slots = ["Head", "Chest", "Upper Arms", "Lower Arms", "Hands", - "Abdomen", "Upper Legs", "Lower Legs", "Feet"] - jewelry_slots = ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"] + armor_slots = [ + "Head", + "Chest", + "Upper Arms", + "Lower Arms", + "Hands", + "Abdomen", + "Upper Legs", + "Lower Legs", + "Feet", + ] + jewelry_slots = [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ] clothing_slots = ["Shirt", "Pants"] - + set_items = [] if primary_set and int(primary_set) in items_by_set: set_items.extend(items_by_set[int(primary_set)][:5]) if secondary_set and int(secondary_set) in items_by_set: set_items.extend(items_by_set[int(secondary_set)][:4]) - + # Sort all candidate items by combined value (armor + spell count) def item_value(item): - spell_bonus = len([s for s in item.get("spell_names", []) if s in required_spells]) * 100 + spell_bonus = ( + len([s for s in item.get("spell_names", []) if s in required_spells]) * 100 + ) return item.get("armor_level", 0) + spell_bonus - + all_candidates = [] for slot_items in items_by_slot.values(): all_candidates.extend(slot_items) - + # Remove duplicates and sort by value unique_candidates = {item["item_id"]: item for item in all_candidates}.values() sorted_candidates = sorted(unique_candidates, key=item_value, reverse=True) - + # Place items greedily by value for item in sorted_candidates: if item["item_id"] in used_items: continue - + slots = determine_item_slots(item) for slot in slots: if slot not in suit["items"]: suit["items"][slot] = item used_items.add(item["item_id"]) - + # Update stats item_set = item.get("item_set_id") if primary_set and item_set == int(primary_set): suit["stats"]["primary_set_count"] += 1 elif secondary_set and item_set == int(secondary_set): suit["stats"]["secondary_set_count"] += 1 - + item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: suit["stats"]["required_spells_found"] += 1 break - + return suit -def place_set_items_optimally(suit, set_items, target_count, used_items, items_by_slot, replace_ok=True): +def place_set_items_optimally( + suit, set_items, target_count, used_items, items_by_slot, replace_ok=True +): """Place set items optimally in available slots.""" placed_count = 0 - + # Sort items by value (armor level, spell count, etc.) def item_value(item): return item.get("armor_level", 0) + len(item.get("spell_names", [])) * 10 - + sorted_items = sorted(set_items, key=item_value, reverse=True) - + for item in sorted_items: if placed_count >= target_count: break - + if item["item_id"] in used_items: continue - + slots = determine_item_slots(item) placed = False - + for slot in slots: # Try to place in empty slot first if slot not in suit["items"]: @@ -4787,37 +5669,59 @@ def place_set_items_optimally(suit, set_items, target_count, used_items, items_b used_items.add(item["item_id"]) placed = True break - + if placed: continue - + return placed_count def fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells): """Fill remaining empty slots with best available items.""" - armor_slots = ["Head", "Chest", "Upper Arms", "Lower Arms", "Hands", - "Abdomen", "Upper Legs", "Lower Legs", "Feet"] - jewelry_slots = ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"] + armor_slots = [ + "Head", + "Chest", + "Upper Arms", + "Lower Arms", + "Hands", + "Abdomen", + "Upper Legs", + "Lower Legs", + "Feet", + ] + jewelry_slots = [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ] clothing_slots = ["Shirt", "Pants"] - + for slot in armor_slots + jewelry_slots + clothing_slots: if slot in suit["items"]: # Slot already filled continue - + if slot not in items_by_slot or not items_by_slot[slot]: continue - + # Find best available item for this slot - available_items = [item for item in items_by_slot[slot] - if item["item_id"] not in used_items] - + available_items = [ + item for item in items_by_slot[slot] if item["item_id"] not in used_items + ] + if available_items: # Score items by armor + spell value def item_value(item): - spell_bonus = len([s for s in item.get("spell_names", []) if s in required_spells]) * 50 + spell_bonus = ( + len( + [s for s in item.get("spell_names", []) if s in required_spells] + ) + * 50 + ) return item.get("armor_level", 0) + spell_bonus - + best_item = max(available_items, key=item_value) suit["items"][slot] = best_item used_items.add(best_item["item_id"]) @@ -4826,14 +5730,16 @@ def fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spe def is_duplicate_suit(new_suit, existing_suits): """Check if this suit is substantially the same as an existing one.""" new_items = set(item["item_id"] for item in new_suit["items"].values()) - + for existing_suit in existing_suits: - existing_items = set(item["item_id"] for item in existing_suit["items"].values()) - + existing_items = set( + item["item_id"] for item in existing_suit["items"].values() + ) + # If 80% or more items are the same, consider it a duplicate if len(new_items & existing_items) / max(len(new_items), 1) >= 0.8: return True - + return False @@ -4845,99 +5751,98 @@ def calculate_suit_stats(suit, primary_set, secondary_set, required_spells): "total_damage_rating": 0, "primary_set_count": 0, "secondary_set_count": 0, - "required_spells_found": 0 + "required_spells_found": 0, } - + found_spells = set() - + for item in suit["items"].values(): # Accumulate stats suit["stats"]["total_armor"] += item.get("armor_level", 0) - suit["stats"]["total_crit_damage"] += item.get("crit_damage_rating", 0) + suit["stats"]["total_crit_damage"] += item.get("crit_damage_rating", 0) suit["stats"]["total_damage_rating"] += item.get("damage_rating", 0) - + # Count set pieces item_set = item.get("item_set_id") if primary_set and item_set == int(primary_set): suit["stats"]["primary_set_count"] += 1 if secondary_set and item_set == int(secondary_set): suit["stats"]["secondary_set_count"] += 1 - + # Count unique required spells item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: found_spells.add(spell) - + suit["stats"]["required_spells_found"] = len(found_spells) class ConstraintSatisfactionSolver: """ MagSuitbuilder-inspired two-phase constraint satisfaction solver. - + Phase 1: ArmorSearcher - Strict set filtering for armor pieces Phase 2: AccessorySearcher - Spell optimization for jewelry/clothing """ - + def __init__(self, characters, constraints): self.characters = characters self.constraints = constraints - self.primary_set = constraints.get('primary_set') - self.secondary_set = constraints.get('secondary_set') - self.min_armor = constraints.get('min_armor', 0) - self.min_crit_damage = constraints.get('min_crit_damage', 0) - self.min_damage_rating = constraints.get('min_damage_rating', 0) - self.min_heal_boost = constraints.get('min_heal_boost', 0) - self.legendary_cantrips = constraints.get('legendary_cantrips', []) - self.protection_spells = constraints.get('protection_spells', []) - self.equipment_status_filter = constraints.get('equipment_status_filter', 'both') - - + self.primary_set = constraints.get("primary_set") + self.secondary_set = constraints.get("secondary_set") + self.min_armor = constraints.get("min_armor", 0) + self.min_crit_damage = constraints.get("min_crit_damage", 0) + self.min_damage_rating = constraints.get("min_damage_rating", 0) + self.min_heal_boost = constraints.get("min_heal_boost", 0) + self.legendary_cantrips = constraints.get("legendary_cantrips", []) + self.protection_spells = constraints.get("protection_spells", []) + self.equipment_status_filter = constraints.get( + "equipment_status_filter", "both" + ) + async def find_optimal_suits(self): """Find optimal equipment combinations using MagSuitbuilder's two-phase algorithm""" try: # Phase 1: ArmorSearcher - Get armor pieces with strict set filtering armor_items = await self._get_armor_items_with_set_filtering() - + if not armor_items: return { "suits": [], "message": "No armor items found matching set requirements", - "total_found": 0 + "total_found": 0, } - + # Phase 2: AccessorySearcher - Get jewelry/clothing (no set restrictions) accessory_items = await self._get_accessory_items() - + # Generate armor combinations (9 slots max) armor_combinations = self._generate_armor_combinations(armor_items) - + # For each viable armor combination, find best accessories suits = [] for armor_combo in armor_combinations[:100]: # Limit to prevent timeout - complete_suit = self._complete_suit_with_accessories(armor_combo, accessory_items) + complete_suit = self._complete_suit_with_accessories( + armor_combo, accessory_items + ) if complete_suit: suits.append(complete_suit) - + # Score and rank suits scored_suits = self._score_suits(suits) - + return { "suits": scored_suits[:20], "total_found": len(scored_suits), "armor_items_available": len(armor_items), - "accessory_items_available": len(accessory_items) + "accessory_items_available": len(accessory_items), } - + except Exception as e: print(f"Error in constraint solver: {e}") - return { - "suits": [], - "message": f"Error: {str(e)}", - "total_found": 0 - } - + return {"suits": [], "message": f"Error: {str(e)}", "total_found": 0} + def _item_meets_constraints(self, item): """Check if an item contributes to meeting the specified constraints""" # Convert item data for consistency @@ -4946,34 +5851,38 @@ class ConstraintSatisfactionSolver: item_spell_list = [s.strip() for s in item_spells.split(",") if s.strip()] else: item_spell_list = item_spells or [] - + item_armor = item.get("armor_level", 0) or 0 item_crit = int(item.get("gear_crit_damage", 0) or 0) item_damage = int(item.get("gear_damage_rating", 0) or 0) item_heal = int(item.get("gear_heal_boost", 0) or 0) item_set = int(item.get("item_set_id", 0) or 0) - + # If no constraints specified, item is useful if it has any meaningful stats has_any_constraints = ( - self.primary_set or self.secondary_set or - self.legendary_cantrips or self.protection_spells or - self.min_armor > 0 or self.min_crit_damage > 0 or - self.min_damage_rating > 0 or self.min_heal_boost > 0 + self.primary_set + or self.secondary_set + or self.legendary_cantrips + or self.protection_spells + or self.min_armor > 0 + or self.min_crit_damage > 0 + or self.min_damage_rating > 0 + or self.min_heal_boost > 0 ) - + if not has_any_constraints: # No constraints specified - any item with decent stats is useful return item_armor > 0 or item_crit > 0 or item_damage > 0 or item_heal > 0 - + # Check if item contributes to any constraint contributes_to_constraint = False - + # Set constraints if self.primary_set and item_set == self.primary_set: contributes_to_constraint = True if self.secondary_set and item_set == self.secondary_set: contributes_to_constraint = True - + # Spell constraints required_spell_names = self.legendary_cantrips + self.protection_spells if required_spell_names: @@ -4982,24 +5891,28 @@ class ConstraintSatisfactionSolver: # Fuzzy matching like in scoring spell_words = item_spell.lower().split() required_words = required_spell.lower().split() - matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) - for req_word in required_words) + matches_all_words = all( + any(req_word in spell_word for spell_word in spell_words) + for req_word in required_words + ) if matches_all_words: contributes_to_constraint = True break if contributes_to_constraint: break - + # Stat constraints - item helps if it has meaningful amounts of required stats if self.min_armor > 0 and item_armor >= 100: # Meaningful armor contribution contributes_to_constraint = True if self.min_crit_damage > 0 and item_crit >= 5: # Meaningful crit contribution contributes_to_constraint = True - if self.min_damage_rating > 0 and item_damage >= 5: # Meaningful damage contribution + if ( + self.min_damage_rating > 0 and item_damage >= 5 + ): # Meaningful damage contribution contributes_to_constraint = True if self.min_heal_boost > 0 and item_heal >= 5: # Meaningful heal contribution contributes_to_constraint = True - + return contributes_to_constraint async def _get_armor_items_with_set_filtering(self): @@ -5022,47 +5935,49 @@ class ConstraintSatisfactionSolver: WHERE i.character_name = ANY(:characters) AND cs.armor_level > 0 """ - - logger.info(f"Filtering armor for constraints: primary_set={self.primary_set}, secondary_set={self.secondary_set}, cantrips={self.legendary_cantrips}, wards={self.protection_spells}") - + + logger.info( + f"Filtering armor for constraints: primary_set={self.primary_set}, secondary_set={self.secondary_set}, cantrips={self.legendary_cantrips}, wards={self.protection_spells}" + ) + # Apply set filtering if any sets are specified set_filters = [] params = {"characters": self.characters} - + if self.primary_set: set_filters.append("enh.item_set = :primary_set") params["primary_set"] = str(self.primary_set) - + if self.secondary_set: set_filters.append("enh.item_set = :secondary_set") params["secondary_set"] = str(self.secondary_set) - + if set_filters: query += f" AND ({' OR '.join(set_filters)})" logger.info(f"Applied set filtering: {' OR '.join(set_filters)}") else: logger.info("No set filtering applied - will use all armor items") - + # Apply equipment status filtering if self.equipment_status_filter == "equipped_only": query += " AND i.current_wielded_location > 0" elif self.equipment_status_filter == "inventory_only": query += " AND i.current_wielded_location = 0" # "both" requires no additional filter - + query += """ GROUP BY i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class, i.current_wielded_location, i.timestamp, enh.item_set, cs.armor_level, rd.int_values->>'374', rd.int_values->>'370', rd.int_values->>'372', rd.int_values->>'218103821' ORDER BY cs.armor_level DESC """ - + async with database.transaction(): rows = await database.fetch_all(query, params) - + items = [] - spells_enum = ENUM_MAPPINGS.get('spells', {}) - + spells_enum = ENUM_MAPPINGS.get("spells", {}) + for row in rows: item = dict(row) # Apply proper slot detection (including clothing) @@ -5070,36 +5985,44 @@ class ConstraintSatisfactionSolver: "object_class": item.get("object_class"), "coverage_mask": item.get("coverage", 0), "name": item.get("name", ""), - "valid_locations": item.get("valid_locations", 0) + "valid_locations": item.get("valid_locations", 0), } slots = determine_item_slots(item_for_slots) item["slot_name"] = ", ".join(slots) - + # Convert spell IDs to spell names spell_ids_str = item.get("spell_ids", "") spell_names = [] if spell_ids_str: - spell_ids = [int(sid.strip()) for sid in spell_ids_str.split(',') if sid.strip()] + spell_ids = [ + int(sid.strip()) for sid in spell_ids_str.split(",") if sid.strip() + ] for spell_id in spell_ids: spell_data = spells_enum.get(spell_id) if spell_data and isinstance(spell_data, dict): - spell_name = spell_data.get('name', f'Unknown Spell {spell_id}') + spell_name = spell_data.get("name", f"Unknown Spell {spell_id}") spell_names.append(spell_name) elif spell_data: spell_names.append(str(spell_data)) - + item["spell_names"] = ", ".join(spell_names) - + # CRITICAL: Only include items that contribute to constraints if self._item_meets_constraints(item): items.append(item) - logger.debug(f"Included armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...)") + logger.debug( + f"Included armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...)" + ) else: - logger.debug(f"Filtered out armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...) - doesn't meet constraints") - - logger.info(f"Armor filtering: {len(items)} items meet constraints out of {len(rows)} total armor items") + logger.debug( + f"Filtered out armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...) - doesn't meet constraints" + ) + + logger.info( + f"Armor filtering: {len(items)} items meet constraints out of {len(rows)} total armor items" + ) return items - + async def _get_accessory_items(self): """Phase 2: AccessorySearcher - Get jewelry/clothing with constraint filtering""" query = """ @@ -5127,14 +6050,14 @@ class ConstraintSatisfactionSolver: OR (rd.int_values->>'218103821')::int & 6 = 6 -- Underwear Pants ) AND LOWER(i.name) NOT LIKE '%robe%' AND LOWER(i.name) NOT LIKE '%pallium%')) -- Exclude robes and palliums from suit building """ - + # Apply equipment status filtering if self.equipment_status_filter == "equipped_only": query += " AND i.current_wielded_location > 0" elif self.equipment_status_filter == "inventory_only": query += " AND i.current_wielded_location = 0" # "both" requires no additional filter - + query += """ GROUP BY i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class, i.current_wielded_location, i.timestamp, @@ -5143,15 +6066,15 @@ class ConstraintSatisfactionSolver: rd.int_values->>'370', rd.int_values->>'372' ORDER BY cs.armor_level DESC """ - + params = {"characters": self.characters} - + async with database.transaction(): rows = await database.fetch_all(query, params) - + items = [] - spells_enum = ENUM_MAPPINGS.get('spells', {}) - + spells_enum = ENUM_MAPPINGS.get("spells", {}) + for row in rows: item = dict(row) # Apply proper slot detection (including clothing) @@ -5159,72 +6082,89 @@ class ConstraintSatisfactionSolver: "object_class": item.get("object_class"), "coverage_mask": item.get("coverage", 0), "name": item.get("name", ""), - "valid_locations": item.get("valid_locations", 0) + "valid_locations": item.get("valid_locations", 0), } slots = determine_item_slots(item_for_slots) item["slot_name"] = ", ".join(slots) - + # Convert spell IDs to spell names spell_ids_str = item.get("spell_ids", "") spell_names = [] if spell_ids_str: - spell_ids = [int(sid.strip()) for sid in spell_ids_str.split(',') if sid.strip()] + spell_ids = [ + int(sid.strip()) for sid in spell_ids_str.split(",") if sid.strip() + ] for spell_id in spell_ids: spell_data = spells_enum.get(spell_id) if spell_data and isinstance(spell_data, dict): - spell_name = spell_data.get('name', f'Unknown Spell {spell_id}') + spell_name = spell_data.get("name", f"Unknown Spell {spell_id}") spell_names.append(spell_name) elif spell_data: spell_names.append(str(spell_data)) - + item["spell_names"] = ", ".join(spell_names) - - # CRITICAL: Only include accessories that contribute to constraints + + # CRITICAL: Only include accessories that contribute to constraints if self._item_meets_constraints(item): items.append(item) - logger.debug(f"Included accessory: {item['name']} (spells: {item['spell_names'][:50]}..., crit: {item.get('gear_crit_damage', 0)}, damage: {item.get('gear_damage_rating', 0)})") + logger.debug( + f"Included accessory: {item['name']} (spells: {item['spell_names'][:50]}..., crit: {item.get('gear_crit_damage', 0)}, damage: {item.get('gear_damage_rating', 0)})" + ) else: - logger.debug(f"Filtered out accessory: {item['name']} (spells: {item['spell_names'][:50]}...) - doesn't meet constraints") - - logger.info(f"Accessory filtering: {len(items)} items meet constraints out of {len(rows)} total accessory items") + logger.debug( + f"Filtered out accessory: {item['name']} (spells: {item['spell_names'][:50]}...) - doesn't meet constraints" + ) + + logger.info( + f"Accessory filtering: {len(items)} items meet constraints out of {len(rows)} total accessory items" + ) return items - + def _generate_armor_combinations(self, armor_items): """Generate ALL viable armor combinations using MagSuitbuilder's recursive approach""" if not armor_items: logger.warning("No armor items provided to _generate_armor_combinations") return [] - + # Group armor by slot armor_by_slot = {} for item in armor_items: slots = item.get("slot_name", "").split(", ") for slot in slots: slot = slot.strip() - if slot and slot not in ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"]: + if slot and slot not in [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ]: if slot not in armor_by_slot: armor_by_slot[slot] = [] armor_by_slot[slot].append(item) - - logger.info(f"Armor grouped by slot: {[(slot, len(items)) for slot, items in armor_by_slot.items()]}") - + + logger.info( + f"Armor grouped by slot: {[(slot, len(items)) for slot, items in armor_by_slot.items()]}" + ) + # Sort slots by number of items (least first for faster pruning) slot_list = sorted(armor_by_slot.items(), key=lambda x: len(x[1])) - + # Initialize search state self.combinations_found = [] self.best_scores = {} # Track best scores to prune bad branches self.max_combinations = 20 # Limit to prevent timeout - + # Start recursive search current_combo = {} self._recursive_armor_search(slot_list, 0, current_combo, set()) - + logger.info(f"Generated {len(self.combinations_found)} armor combinations") - + # Return unique combinations sorted by score - return self.combinations_found[:self.max_combinations] - + return self.combinations_found[: self.max_combinations] + def _recursive_armor_search(self, slot_list, index, current_combo, used_items): """Recursive backtracking search (MagSuitbuilder approach)""" # Base case: we've processed all slots @@ -5232,7 +6172,7 @@ class ConstraintSatisfactionSolver: if current_combo: # Calculate combo score combo_score = self._calculate_combo_score(current_combo) - + # Only keep if it's a good combination if combo_score >= 50: # Minimum threshold # Check if we already have a similar combo @@ -5240,20 +6180,21 @@ class ConstraintSatisfactionSolver: self.combinations_found.append(dict(current_combo)) # Sort by score to keep best ones self.combinations_found.sort( - key=lambda x: self._calculate_combo_score(x), - reverse=True + key=lambda x: self._calculate_combo_score(x), reverse=True ) # Keep only top combinations if len(self.combinations_found) > self.max_combinations * 2: - self.combinations_found = self.combinations_found[:self.max_combinations] + self.combinations_found = self.combinations_found[ + : self.max_combinations + ] return - + # Stop if we've found enough good combinations if len(self.combinations_found) >= self.max_combinations: return - + slot_name, items = slot_list[index] - + # Try each item in this slot for item in items[:5]: # Limit items per slot to prevent explosion if item["item_id"] not in used_items: @@ -5262,94 +6203,110 @@ class ConstraintSatisfactionSolver: # Push: Add item to current combination current_combo[slot_name] = item used_items.add(item["item_id"]) - + # Recurse to next slot - self._recursive_armor_search(slot_list, index + 1, current_combo, used_items) - + self._recursive_armor_search( + slot_list, index + 1, current_combo, used_items + ) + # Pop: Remove item (backtrack) del current_combo[slot_name] used_items.remove(item["item_id"]) - + # Also try skipping this slot entirely (empty slot) self._recursive_armor_search(slot_list, index + 1, current_combo, used_items) - + def _calculate_combo_score(self, combo): """Quick score calculation for pruning""" primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum(1 for item in combo.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int) - secondary_count = sum(1 for item in combo.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int) - + + primary_count = sum( + 1 + for item in combo.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int + ) + secondary_count = sum( + 1 + for item in combo.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int + ) + score = 0 if primary_count >= 5: score += 50 else: score += primary_count * 8 - + if secondary_count >= 4: score += 40 else: score += secondary_count * 8 - + return score - + def _should_try_item(self, item, current_combo): """Check if item is worth trying - must meet constraints and set logic""" # CRITICAL: Item must meet constraints to be considered if not self._item_meets_constraints(item): return False - + primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None item_set_int = int(item.get("item_set_id", 0) or 0) - + # Count current sets - primary_count = sum(1 for i in current_combo.values() - if int(i.get("item_set_id", 0) or 0) == primary_set_int) - secondary_count = sum(1 for i in current_combo.values() - if int(i.get("item_set_id", 0) or 0) == secondary_set_int) - + primary_count = sum( + 1 + for i in current_combo.values() + if int(i.get("item_set_id", 0) or 0) == primary_set_int + ) + secondary_count = sum( + 1 + for i in current_combo.values() + if int(i.get("item_set_id", 0) or 0) == secondary_set_int + ) + # Apply MagSuitbuilder's logic but with constraint checking if item_set_int == primary_set_int and primary_count < 5: return True elif item_set_int == secondary_set_int and secondary_count < 4: return True - elif not item_set_int: # Non-set items allowed if we have room and they meet constraints + elif ( + not item_set_int + ): # Non-set items allowed if we have room and they meet constraints return len(current_combo) < 9 else: # Wrong set item return False - + def _is_duplicate_combo(self, combo): """Check if we already have this combination""" combo_items = set(item["item_id"] for item in combo.values()) - + for existing in self.combinations_found: existing_items = set(item["item_id"] for item in existing.values()) if combo_items == existing_items: return True return False - + def _build_optimal_combination(self, armor_by_slot, strategy="balanced"): """Build a single combination using specified strategy""" combination = {} primary_count = 0 secondary_count = 0 - + primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - + # Process slots in order for slot, items in armor_by_slot.items(): best_item = None best_score = -1 - + for item in items: item_set_int = int(item.get("item_set_id", 0) or 0) score = 0 - + if strategy == "armor": # Prioritize armor level score = item.get("armor_level", 0) @@ -5357,7 +6314,7 @@ class ConstraintSatisfactionSolver: score += 50 elif item_set_int == secondary_set_int: score += 30 - + elif strategy == "primary": # Maximize primary set if item_set_int == primary_set_int and primary_count < 5: @@ -5366,7 +6323,7 @@ class ConstraintSatisfactionSolver: score = 500 else: score = item.get("armor_level", 0) - + elif strategy == "balanced": # Balance sets according to requirements if item_set_int == primary_set_int and primary_count < 5: @@ -5375,11 +6332,11 @@ class ConstraintSatisfactionSolver: score = 600 else: score = item.get("armor_level", 0) / 10 - + if score > best_score: best_item = item best_score = score - + if best_item: combination[slot] = best_item item_set_int = int(best_item.get("item_set_id", 0) or 0) @@ -5387,49 +6344,57 @@ class ConstraintSatisfactionSolver: primary_count += 1 elif item_set_int == secondary_set_int: secondary_count += 1 - + return combination if combination else None - + def _build_equipped_preferred_combination(self, armor_by_slot): """Build combination preferring currently equipped items""" combination = {} - + for slot, items in armor_by_slot.items(): # Prefer equipped items - equipped_items = [item for item in items if item.get("current_wielded_location", 0) > 0] + equipped_items = [ + item for item in items if item.get("current_wielded_location", 0) > 0 + ] if equipped_items: # Take the best equipped item for this slot - combination[slot] = max(equipped_items, key=lambda x: x.get("armor_level", 0)) + combination[slot] = max( + equipped_items, key=lambda x: x.get("armor_level", 0) + ) elif items: # Fall back to best available combination[slot] = items[0] - + return combination if combination else None - + def _complete_suit_with_accessories(self, armor_combo, accessory_items): """Complete armor combination with systematic jewelry optimization""" complete_suit = {"items": dict(armor_combo)} - + # Only optimize accessories if there are accessory-related constraints has_accessory_constraints = ( - self.legendary_cantrips or - self.protection_spells or - self.min_crit_damage > 0 or - self.min_damage_rating > 0 or - self.min_heal_boost > 0 + self.legendary_cantrips + or self.protection_spells + or self.min_crit_damage > 0 + or self.min_damage_rating > 0 + or self.min_heal_boost > 0 ) - + if has_accessory_constraints: # Systematically optimize jewelry slots - jewelry_items = [item for item in accessory_items if item.get("object_class") == 4] + jewelry_items = [ + item for item in accessory_items if item.get("object_class") == 4 + ] self._optimize_jewelry_systematically(complete_suit, jewelry_items) - + # Also optimize clothing slots - clothing_items = [item for item in accessory_items if item.get("object_class") == 3] + clothing_items = [ + item for item in accessory_items if item.get("object_class") == 3 + ] self._optimize_clothing_systematically(complete_suit, clothing_items) - + return complete_suit - + def _optimize_jewelry_systematically(self, suit, jewelry_items): """ Systematically optimize all 6 jewelry slots for maximum benefit. @@ -5437,17 +6402,17 @@ class ConstraintSatisfactionSolver: """ # Group jewelry by slot jewelry_by_slot = self._group_jewelry_by_slot(jewelry_items) - + # Define jewelry slot priority (amulets often have best spells) jewelry_slot_priority = [ - "Neck", # Amulets/necklaces often have legendary cantrips - "Left Ring", # Rings often have high ratings + "Neck", # Amulets/necklaces often have legendary cantrips + "Left Ring", # Rings often have high ratings "Right Ring", - "Left Wrist", # Bracelets + "Left Wrist", # Bracelets "Right Wrist", - "Trinket" # Special items + "Trinket", # Special items ] - + # Track spells already covered by armor covered_spells = set() for item in suit["items"].values(): @@ -5457,7 +6422,7 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + # Optimize each jewelry slot for slot in jewelry_slot_priority: if slot in jewelry_by_slot and jewelry_by_slot[slot]: @@ -5466,7 +6431,7 @@ class ConstraintSatisfactionSolver: ) if best_item: suit["items"][slot] = best_item - + # Update covered spells item_spells = best_item.get("spell_names", "") if item_spells: @@ -5474,44 +6439,52 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + def _group_jewelry_by_slot(self, jewelry_items): """Group jewelry items by their possible slots""" jewelry_by_slot = { - "Neck": [], "Left Ring": [], "Right Ring": [], - "Left Wrist": [], "Right Wrist": [], "Trinket": [] + "Neck": [], + "Left Ring": [], + "Right Ring": [], + "Left Wrist": [], + "Right Wrist": [], + "Trinket": [], } - + for item in jewelry_items: possible_slots = determine_item_slots(item) for slot in possible_slots: if slot in jewelry_by_slot: jewelry_by_slot[slot].append(item) - + return jewelry_by_slot - - def _find_best_jewelry_for_slot(self, slot, slot_items, current_suit, covered_spells): + + def _find_best_jewelry_for_slot( + self, slot, slot_items, current_suit, covered_spells + ): """Find the best jewelry item for a specific slot""" if not slot_items: return None - + required_spells = self.legendary_cantrips + self.protection_spells best_item = None best_score = -1 - + for item in slot_items: - score = self._calculate_jewelry_item_score(item, required_spells, covered_spells) + score = self._calculate_jewelry_item_score( + item, required_spells, covered_spells + ) if score > best_score: best_score = score best_item = item - + # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) # Score of 0 means it doesn't meet constraints, don't use it if best_score <= 0: return None - + return best_item - + def _optimize_clothing_systematically(self, suit, clothing_items): """ Systematically optimize clothing slots (Shirt and Pants) for maximum benefit. @@ -5519,10 +6492,10 @@ class ConstraintSatisfactionSolver: """ # Group clothing by slot clothing_by_slot = self._group_clothing_by_slot(clothing_items) - + # Define clothing slot priority clothing_slot_priority = ["Shirt", "Pants"] - + # Track spells already covered by armor and jewelry covered_spells = set() for item in suit["items"].values(): @@ -5532,7 +6505,7 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + # Optimize each clothing slot for slot in clothing_slot_priority: if slot in clothing_by_slot and clothing_by_slot[slot]: @@ -5541,7 +6514,7 @@ class ConstraintSatisfactionSolver: ) if best_item: suit["items"][slot] = best_item - + # Update covered spells item_spells = best_item.get("spell_names", "") if item_spells: @@ -5549,49 +6522,53 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + def _group_clothing_by_slot(self, clothing_items): """Group clothing items by their possible slots""" clothing_by_slot = {"Shirt": [], "Pants": []} - + for item in clothing_items: possible_slots = determine_item_slots(item) for slot in possible_slots: if slot in clothing_by_slot: clothing_by_slot[slot].append(item) - + return clothing_by_slot - - def _find_best_clothing_for_slot(self, slot, slot_items, current_suit, covered_spells): + + def _find_best_clothing_for_slot( + self, slot, slot_items, current_suit, covered_spells + ): """Find the best clothing item for a specific slot""" if not slot_items: return None - + required_spells = self.legendary_cantrips + self.protection_spells best_item = None best_score = -1 - + for item in slot_items: - score = self._calculate_clothing_item_score(item, required_spells, covered_spells) + score = self._calculate_clothing_item_score( + item, required_spells, covered_spells + ) if score > best_score: best_score = score best_item = item - - # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) + + # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) # Score of 0 means it doesn't meet constraints, don't use it if best_score <= 0: return None - + return best_item - + def _calculate_clothing_item_score(self, item, required_spells, covered_spells): """Calculate optimization score for a clothing item""" # CRITICAL: Items that don't meet constraints get score 0 if not self._item_meets_constraints(item): return 0 - + score = 0 - + # Get item spells item_spells = set() spell_data = item.get("spell_names", "") @@ -5600,37 +6577,40 @@ class ConstraintSatisfactionSolver: item_spells.update(spell_data.split(", ")) elif isinstance(spell_data, list): item_spells.update(spell_data) - + # High bonus for required spells that aren't covered yet for spell in required_spells: for item_spell in item_spells: - if spell.lower() in item_spell.lower() and item_spell not in covered_spells: + if ( + spell.lower() in item_spell.lower() + and item_spell not in covered_spells + ): score += 100 # Very high bonus for uncovered required spells - + # Bonus for any legendary spells for spell in item_spells: if "legendary" in spell.lower(): score += 20 - + # Rating bonuses (clothing can have ratings too, only count if meeting constraints) score += (item.get("gear_crit_damage", 0) or 0) * 2 score += (item.get("gear_damage_rating", 0) or 0) * 2 score += (item.get("gear_heal_boost", 0) or 0) * 1 - + # Prefer unequipped items slightly if item.get("current_wielded_location", 0) == 0: score += 5 - + return score - + def _calculate_jewelry_item_score(self, item, required_spells, covered_spells): """Calculate optimization score for a jewelry item""" # CRITICAL: Items that don't meet constraints get score 0 if not self._item_meets_constraints(item): return 0 - + score = 0 - + # Get item spells item_spells = set() spell_data = item.get("spell_names", "") @@ -5639,48 +6619,64 @@ class ConstraintSatisfactionSolver: item_spells.update(spell_data.split(", ")) elif isinstance(spell_data, list): item_spells.update(spell_data) - + # High bonus for required spells that aren't covered yet for spell in required_spells: for item_spell in item_spells: - if spell.lower() in item_spell.lower() and item_spell not in covered_spells: + if ( + spell.lower() in item_spell.lower() + and item_spell not in covered_spells + ): score += 100 # Very high bonus for uncovered required spells - + # Bonus for any legendary spells for spell in item_spells: if "legendary" in spell.lower(): score += 20 - + # Rating bonuses (only count if meeting constraints) score += (item.get("gear_crit_damage", 0) or 0) * 2 score += (item.get("gear_damage_rating", 0) or 0) * 2 score += (item.get("gear_heal_boost", 0) or 0) * 1 - + # Prefer unequipped items slightly (so we don't disrupt current builds) if item.get("current_wielded_location", 0) == 0: score += 5 - + return score - + def _calculate_jewelry_score_bonus(self, items): """ Calculate scoring bonus for jewelry optimization. Phase 3D.3 implementation. """ - jewelry_slots = ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"] + jewelry_slots = [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ] jewelry_items = [item for slot, item in items.items() if slot in jewelry_slots] - + bonus = 0 - + # Slot coverage bonus - 2 points per jewelry slot filled bonus += len(jewelry_items) * 2 - + # Rating bonus from jewelry (jewelry often has high ratings) for item in jewelry_items: - bonus += int(item.get("gear_crit_damage", 0) or 0) * 0.1 # 0.1 points per crit damage point - bonus += int(item.get("gear_damage_rating", 0) or 0) * 0.1 # 0.1 points per damage rating point - bonus += int(item.get("gear_heal_boost", 0) or 0) * 0.05 # 0.05 points per heal boost point - + bonus += ( + int(item.get("gear_crit_damage", 0) or 0) * 0.1 + ) # 0.1 points per crit damage point + bonus += ( + int(item.get("gear_damage_rating", 0) or 0) * 0.1 + ) # 0.1 points per damage rating point + bonus += ( + int(item.get("gear_heal_boost", 0) or 0) * 0.05 + ) # 0.05 points per heal boost point + # Spell diversity bonus from jewelry jewelry_spells = set() for item in jewelry_items: @@ -5690,46 +6686,71 @@ class ConstraintSatisfactionSolver: jewelry_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): jewelry_spells.update(item_spells) - + # Bonus for legendary spells from jewelry - legendary_count = sum(1 for spell in jewelry_spells if "legendary" in spell.lower()) + legendary_count = sum( + 1 for spell in jewelry_spells if "legendary" in spell.lower() + ) bonus += legendary_count * 3 # 3 points per legendary spell from jewelry - + # Bonus for spell diversity from jewelry - bonus += min(5, len(jewelry_spells) * 0.5) # Up to 5 points for jewelry spell diversity - + bonus += min( + 5, len(jewelry_spells) * 0.5 + ) # Up to 5 points for jewelry spell diversity + return bonus - + def _create_disqualified_suit_result(self, index, items, reason): """Create a result for a disqualified suit (overlapping cantrips)""" # Extract basic info for display primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum(1 for item in items.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int) - secondary_count = sum(1 for item in items.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int) - + + primary_count = sum( + 1 + for item in items.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int + ) + secondary_count = sum( + 1 + for item in items.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int + ) + return { "id": index + 1, "score": 0, # Disqualified suits get 0 score "disqualified": True, "disqualification_reason": reason, - "primary_set": self._get_set_name(self.primary_set) if self.primary_set else None, + "primary_set": self._get_set_name(self.primary_set) + if self.primary_set + else None, "primary_set_count": primary_count, - "secondary_set": self._get_set_name(self.secondary_set) if self.secondary_set else None, + "secondary_set": self._get_set_name(self.secondary_set) + if self.secondary_set + else None, "secondary_set_count": secondary_count, "spell_coverage": 0, - "total_armor": sum(item.get("armor_level", 0) or 0 for item in items.values()), + "total_armor": sum( + item.get("armor_level", 0) or 0 for item in items.values() + ), "stats": { "primary_set_count": primary_count, "secondary_set_count": secondary_count, "required_spells_found": 0, - "total_armor": sum(item.get("armor_level", 0) or 0 for item in items.values()), - "total_crit_damage": sum(int(item.get("gear_crit_damage", 0) or 0) for item in items.values()), - "total_damage_rating": sum(int(item.get("gear_damage_rating", 0) or 0) for item in items.values()), - "total_heal_boost": sum(int(item.get("gear_heal_boost", 0) or 0) for item in items.values()) + "total_armor": sum( + item.get("armor_level", 0) or 0 for item in items.values() + ), + "total_crit_damage": sum( + int(item.get("gear_crit_damage", 0) or 0) for item in items.values() + ), + "total_damage_rating": sum( + int(item.get("gear_damage_rating", 0) or 0) + for item in items.values() + ), + "total_heal_boost": sum( + int(item.get("gear_heal_boost", 0) or 0) for item in items.values() + ), }, "items": { slot: { @@ -5741,80 +6762,106 @@ class ConstraintSatisfactionSolver: "crit_damage_rating": item.get("gear_crit_damage", 0), "damage_rating": item.get("gear_damage_rating", 0), "heal_boost": item.get("gear_heal_boost", 0), - "spell_names": item.get("spell_names", "").split(", ") if item.get("spell_names") else [], - "slot_name": item.get("slot_name", slot) + "spell_names": item.get("spell_names", "").split(", ") + if item.get("spell_names") + else [], + "slot_name": item.get("slot_name", slot), } for slot, item in items.items() - } + }, } - + def _score_suits(self, suits): """Score suits using proper ranking priorities""" scored_suits = [] - + for i, suit in enumerate(suits): items = suit["items"] - + # FIRST: Check for overlapping REQUESTED cantrips (HARD REQUIREMENT) - requested_cantrips = self.legendary_cantrips # Only the cantrips user asked for + requested_cantrips = ( + self.legendary_cantrips + ) # Only the cantrips user asked for all_cantrips = [] cantrip_sources = {} # Track which item has which requested cantrip has_overlap = False overlap_reason = "" - + # DEBUG: Log suit evaluation logger.info(f"Evaluating suit {i}: {len(items)} items") for slot, item in items.items(): - logger.info(f" {slot}: {item.get('name', 'Unknown')} (set: {item.get('item_set_id', 'None')}) spells: {item.get('spell_names', 'None')}") - + logger.info( + f" {slot}: {item.get('name', 'Unknown')} (set: {item.get('item_set_id', 'None')}) spells: {item.get('spell_names', 'None')}" + ) + # Track overlaps but don't disqualify - just note them for scoring penalties overlap_penalty = 0 overlap_notes = [] - + if requested_cantrips: logger.info(f"Checking for overlapping cantrips: {requested_cantrips}") - + # Define ward spells that are allowed to overlap (protection is stackable) ward_spells = { - "legendary flame ward", "legendary frost ward", "legendary acid ward", - "legendary storm ward", "legendary slashing ward", "legendary piercing ward", - "legendary bludgeoning ward", "legendary armor" + "legendary flame ward", + "legendary frost ward", + "legendary acid ward", + "legendary storm ward", + "legendary slashing ward", + "legendary piercing ward", + "legendary bludgeoning ward", + "legendary armor", } - + for slot, item in items.items(): item_spells = item.get("spell_names", []) if isinstance(item_spells, str): item_spells = item_spells.split(", ") - + for spell in item_spells: # Check if this spell is one of the REQUESTED cantrips for requested in requested_cantrips: # More precise matching - check if the requested cantrip is part of the spell name spell_words = spell.lower().split() requested_words = requested.lower().split() - + # Check if all words in the requested cantrip appear in the spell - matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) - for req_word in requested_words) - + matches_all_words = all( + any( + req_word in spell_word for spell_word in spell_words + ) + for req_word in requested_words + ) + if matches_all_words: - logger.info(f" Found match: '{requested}' matches '{spell}' on {slot}") - + logger.info( + f" Found match: '{requested}' matches '{spell}' on {slot}" + ) + # Check if this is a ward spell (protection) - these are allowed to overlap is_ward_spell = requested.lower() in ward_spells - - if requested in cantrip_sources and cantrip_sources[requested] != slot: + + if ( + requested in cantrip_sources + and cantrip_sources[requested] != slot + ): if is_ward_spell: # Ward spells are allowed to overlap - no penalty - logger.info(f" Ward overlap allowed: {requested} on {cantrip_sources[requested]} and {slot}") + logger.info( + f" Ward overlap allowed: {requested} on {cantrip_sources[requested]} and {slot}" + ) else: # Non-ward spells overlapping - apply penalty but don't disqualify - overlap_penalty += 50 # 50 point penalty per overlap + overlap_penalty += ( + 50 # 50 point penalty per overlap + ) overlap_note = f"Overlapping {requested} on {cantrip_sources[requested]} and {slot}" overlap_notes.append(overlap_note) - logger.warning(f" OVERLAP PENALTY: {overlap_note}") + logger.warning( + f" OVERLAP PENALTY: {overlap_note}" + ) cantrip_sources[requested] = slot - + # Also track all legendary cantrips for bonus scoring if "legendary" in spell.lower(): all_cantrips.append(spell) @@ -5824,54 +6871,72 @@ class ConstraintSatisfactionSolver: item_spells = item.get("spell_names", []) if isinstance(item_spells, str): item_spells = item_spells.split(", ") - + for spell in item_spells: if "legendary" in spell.lower(): all_cantrips.append(spell) - + # Proceed with scoring (including any overlap penalties) score = 0 - + # Count set pieces primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum(1 for item in items.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int) - secondary_count = sum(1 for item in items.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int) - + + primary_count = sum( + 1 + for item in items.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int + ) + secondary_count = sum( + 1 + for item in items.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int + ) + # PRIORITY 1: Equipment Set Completion (300-500 points) - only if sets requested if self.primary_set: if primary_count >= 5: score += 300 # Full primary set else: score += primary_count * 50 # 50 points per piece - + if self.secondary_set: if secondary_count >= 4: score += 200 # Full secondary set else: score += secondary_count * 40 # 40 points per piece - + # Calculate total ratings - total_armor = sum(item.get("armor_level", 0) or 0 for item in items.values()) - total_crit_damage = sum(int(item.get("gear_crit_damage", 0) or 0) for item in items.values()) - total_damage_rating = sum(int(item.get("gear_damage_rating", 0) or 0) for item in items.values()) - total_heal_boost = sum(int(item.get("gear_heal_boost", 0) or 0) for item in items.values()) - + total_armor = sum( + item.get("armor_level", 0) or 0 for item in items.values() + ) + total_crit_damage = sum( + int(item.get("gear_crit_damage", 0) or 0) for item in items.values() + ) + total_damage_rating = sum( + int(item.get("gear_damage_rating", 0) or 0) for item in items.values() + ) + total_heal_boost = sum( + int(item.get("gear_heal_boost", 0) or 0) for item in items.values() + ) + # PRIORITY 2: Crit Damage Rating (10 points per point) - only if requested if self.min_crit_damage > 0: score += total_crit_damage * 10 else: - score += total_crit_damage * 1 # Minor bonus if not specifically requested - + score += ( + total_crit_damage * 1 + ) # Minor bonus if not specifically requested + # PRIORITY 3: Damage Rating (8 points per point) - only if requested if self.min_damage_rating > 0: score += total_damage_rating * 8 else: - score += total_damage_rating * 1 # Minor bonus if not specifically requested - + score += ( + total_damage_rating * 1 + ) # Minor bonus if not specifically requested + # BONUS: Required spell coverage (up to 50 points) all_spells = set() for item in items.values(): @@ -5881,7 +6946,7 @@ class ConstraintSatisfactionSolver: all_spells.update(item_spells.split(", ")) else: all_spells.update(item_spells) - + required_spells = self.legendary_cantrips + self.protection_spells if required_spells: # Use fuzzy matching for spell coverage like we do for overlap detection @@ -5891,78 +6956,107 @@ class ConstraintSatisfactionSolver: # More precise matching - check if the requested cantrip is part of the spell name spell_words = actual_spell.lower().split() required_words = required.lower().split() - + # Check if all words in the requested cantrip appear in the spell - matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) - for req_word in required_words) - + matches_all_words = all( + any(req_word in spell_word for spell_word in spell_words) + for req_word in required_words + ) + if matches_all_words: spell_coverage_count += 1 break # Found this required spell, move to next - - logger.info(f"Spell coverage: {spell_coverage_count}/{len(required_spells)} required spells found") + + logger.info( + f"Spell coverage: {spell_coverage_count}/{len(required_spells)} required spells found" + ) score += (spell_coverage_count / len(required_spells)) * 50 - + # BONUS: Total unique cantrips (2 points each) score += len(all_cantrips) * 2 - + # BONUS: Total armor level (only if armor minimum requested) if self.min_armor > 0: score += total_armor * 0.1 # Higher bonus if specifically requested else: score += total_armor * 0.01 # Minor bonus for general armor - + # BONUS: Meeting minimum requirements (10 points each) armor_req_met = self.min_armor > 0 and total_armor >= self.min_armor - crit_req_met = self.min_crit_damage > 0 and total_crit_damage >= self.min_crit_damage - damage_req_met = self.min_damage_rating > 0 and total_damage_rating >= self.min_damage_rating - + crit_req_met = ( + self.min_crit_damage > 0 and total_crit_damage >= self.min_crit_damage + ) + damage_req_met = ( + self.min_damage_rating > 0 + and total_damage_rating >= self.min_damage_rating + ) + if armor_req_met: score += 10 if crit_req_met: score += 10 if damage_req_met: score += 10 - + # Apply overlap penalty score -= overlap_penalty - + # CRITICAL: Heavy penalty for not meeting required minimums if self.min_armor > 0 and total_armor < self.min_armor: score -= 200 # Heavy penalty for not meeting armor requirement if self.min_crit_damage > 0 and total_crit_damage < self.min_crit_damage: - score -= 200 # Heavy penalty for not meeting crit requirement - if self.min_damage_rating > 0 and total_damage_rating < self.min_damage_rating: + score -= 200 # Heavy penalty for not meeting crit requirement + if ( + self.min_damage_rating > 0 + and total_damage_rating < self.min_damage_rating + ): score -= 200 # Heavy penalty for not meeting damage requirement if self.min_heal_boost > 0 and total_heal_boost < self.min_heal_boost: score -= 200 # Heavy penalty for not meeting heal requirement - + # CRITICAL: Heavy penalty for not getting required set counts if self.primary_set and primary_count < 5: - score -= (5 - primary_count) * 30 # 30 point penalty per missing primary set piece + score -= ( + 5 - primary_count + ) * 30 # 30 point penalty per missing primary set piece if self.secondary_set and secondary_count < 4: - score -= (4 - secondary_count) * 25 # 25 point penalty per missing secondary set piece - - logger.info(f"Suit {i} final score: {int(score)} (primary: {primary_count}, secondary: {secondary_count}, armor: {total_armor}, crit: {total_crit_damage}, damage: {total_damage_rating}, overlap_penalty: {overlap_penalty})") - + score -= ( + 4 - secondary_count + ) * 25 # 25 point penalty per missing secondary set piece + + logger.info( + f"Suit {i} final score: {int(score)} (primary: {primary_count}, secondary: {secondary_count}, armor: {total_armor}, crit: {total_crit_damage}, damage: {total_damage_rating}, overlap_penalty: {overlap_penalty})" + ) + # Create suit result suit_result = { "id": i + 1, "score": int(score), - "primary_set": self._get_set_name(self.primary_set) if self.primary_set else None, + "primary_set": self._get_set_name(self.primary_set) + if self.primary_set + else None, "primary_set_count": primary_count, - "secondary_set": self._get_set_name(self.secondary_set) if self.secondary_set else None, + "secondary_set": self._get_set_name(self.secondary_set) + if self.secondary_set + else None, "secondary_set_count": secondary_count, "spell_coverage": len(all_spells), "total_armor": total_armor, "stats": { "primary_set_count": primary_count, "secondary_set_count": secondary_count, - "required_spells_found": len([spell for spell in self.legendary_cantrips + self.protection_spells if spell in all_spells]), + "required_spells_found": len( + [ + spell + for spell in self.legendary_cantrips + + self.protection_spells + if spell in all_spells + ] + ), "total_armor": total_armor, "total_crit_damage": total_crit_damage, "total_damage_rating": total_damage_rating, - "total_heal_boost": total_heal_boost + "total_heal_boost": total_heal_boost, }, "items": { slot: { @@ -5974,51 +7068,61 @@ class ConstraintSatisfactionSolver: "crit_damage_rating": item.get("gear_crit_damage", 0), "damage_rating": item.get("gear_damage_rating", 0), "heal_boost": item.get("gear_heal_boost", 0), - "spell_names": item.get("spell_names", "").split(", ") if item.get("spell_names") else [], - "slot_name": item.get("slot_name", slot) + "spell_names": item.get("spell_names", "").split(", ") + if item.get("spell_names") + else [], + "slot_name": item.get("slot_name", slot), } for slot, item in items.items() }, - "notes": overlap_notes if overlap_notes else [] + "notes": overlap_notes if overlap_notes else [], } - + # Add comprehensive constraint analysis - add_suit_analysis(suit_result, self.primary_set, self.secondary_set, - self.legendary_cantrips + self.protection_spells, - self.min_armor, self.min_crit_damage, self.min_damage_rating, self.min_heal_boost) - + add_suit_analysis( + suit_result, + self.primary_set, + self.secondary_set, + self.legendary_cantrips + self.protection_spells, + self.min_armor, + self.min_crit_damage, + self.min_damage_rating, + self.min_heal_boost, + ) + scored_suits.append(suit_result) - + # Sort by score descending scored_suits.sort(key=lambda x: x["score"], reverse=True) return scored_suits - + def _get_set_name(self, set_id): """Get human-readable set name from set ID""" if not set_id: return "No Set" - + set_id_str = str(set_id) - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) - + dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) + attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) + if set_id_str in attribute_set_info: return attribute_set_info[set_id_str] else: return f"Unknown Set ({set_id})" - - + def solve(self, max_results, max_iterations=1000): """ Solve the constraint satisfaction problem using iterative search. - + Returns the top solutions found within the iteration limit. """ - logger.info(f"CSP Solver: Starting search with {len(self.candidate_items)} items, {max_iterations} max iterations") - + logger.info( + f"CSP Solver: Starting search with {len(self.candidate_items)} items, {max_iterations} max iterations" + ) + solutions = [] iteration = 0 - + # Strategy 1: Set-First Greedy Search (50% of iterations) for i in range(max_iterations // 2): solution = self._solve_set_first_greedy() @@ -6027,7 +7131,7 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: # Get extra solutions for ranking break iteration += 1 - + # Strategy 2: Backtracking Search (30% of iterations) for i in range(max_iterations * 3 // 10): solution = self._solve_backtracking() @@ -6036,7 +7140,7 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: break iteration += 1 - + # Strategy 3: Random Restarts (20% of iterations) for i in range(max_iterations // 5): solution = self._solve_random_restart() @@ -6045,201 +7149,246 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: break iteration += 1 - - logger.info(f"CSP Solver: Found {len(solutions)} solutions in {iteration} iterations") - + + logger.info( + f"CSP Solver: Found {len(solutions)} solutions in {iteration} iterations" + ) + # Score and rank all solutions for solution in solutions: self._calculate_solution_stats(solution) solution["score"] = self._calculate_solution_score(solution) solution["id"] = solutions.index(solution) + 1 self._add_solution_analysis(solution) - + # Return top solutions solutions.sort(key=lambda x: x["score"], reverse=True) return solutions[:max_results] - + def _solve_set_first_greedy(self): """ Greedy algorithm that prioritizes set requirements first. """ solution = {"items": {}, "stats": {}} used_items = set() - + # Phase 1: Place primary set items primary_placed = 0 if self.primary_set and self.primary_set in self.items_by_set: - primary_items = sorted(self.items_by_set[self.primary_set], - key=lambda x: self._item_value(x, solution), reverse=True) - + primary_items = sorted( + self.items_by_set[self.primary_set], + key=lambda x: self._item_value(x, solution), + reverse=True, + ) + for item in primary_items: if primary_placed >= 5: # Only need 5 for primary set break if item["item_id"] in used_items: continue - + # Find best slot for this item possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot(possible_slots, solution["items"]) - + best_slot = self._find_best_available_slot( + possible_slots, solution["items"] + ) + if best_slot: solution["items"][best_slot] = item used_items.add(item["item_id"]) primary_placed += 1 - + # Phase 2: Place secondary set items secondary_placed = 0 if self.secondary_set and self.secondary_set in self.items_by_set: - secondary_items = sorted(self.items_by_set[self.secondary_set], - key=lambda x: self._item_value(x, solution), reverse=True) - + secondary_items = sorted( + self.items_by_set[self.secondary_set], + key=lambda x: self._item_value(x, solution), + reverse=True, + ) + for item in secondary_items: if secondary_placed >= 4: # Only need 4 for secondary set break if item["item_id"] in used_items: continue - + possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot(possible_slots, solution["items"]) - + best_slot = self._find_best_available_slot( + possible_slots, solution["items"] + ) + if best_slot: solution["items"][best_slot] = item used_items.add(item["item_id"]) secondary_placed += 1 - + # Phase 3: Place items with required spells for spell in self.required_spells: if spell in self.items_with_spells: - spell_items = sorted(self.items_with_spells[spell], - key=lambda x: self._item_value(x), reverse=True) - + spell_items = sorted( + self.items_with_spells[spell], + key=lambda x: self._item_value(x), + reverse=True, + ) + # Try to place at least one item with this spell placed_spell = False for item in spell_items: if item["item_id"] in used_items: continue - + possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot(possible_slots, solution["items"]) - + best_slot = self._find_best_available_slot( + possible_slots, solution["items"] + ) + if best_slot: # Check if replacing existing item is beneficial existing_item = solution["items"].get(best_slot) - if existing_item is None or self._should_replace_item(existing_item, item): + if existing_item is None or self._should_replace_item( + existing_item, item + ): if existing_item: used_items.discard(existing_item["item_id"]) solution["items"][best_slot] = item used_items.add(item["item_id"]) placed_spell = True break - + # Phase 4: Optimally fill remaining slots to maximize set bonuses self._optimize_remaining_slots(solution, used_items) - + return solution if solution["items"] else None - + def _optimize_remaining_slots(self, solution, used_items): """Optimally fill remaining slots to maximize constraint satisfaction.""" # Calculate current set counts - primary_count = sum(1 for item in solution["items"].values() - if item.get("item_set_id") == self.primary_set) - secondary_count = sum(1 for item in solution["items"].values() - if item.get("item_set_id") == self.secondary_set) - + primary_count = sum( + 1 + for item in solution["items"].values() + if item.get("item_set_id") == self.primary_set + ) + secondary_count = sum( + 1 + for item in solution["items"].values() + if item.get("item_set_id") == self.secondary_set + ) + # Fill slots prioritizing most needed sets for slot in self.all_slots: if slot in solution["items"]: continue # Already filled - + if slot not in self.items_by_slot: continue - - available_items = [item for item in self.items_by_slot[slot] - if item["item_id"] not in used_items] - + + available_items = [ + item + for item in self.items_by_slot[slot] + if item["item_id"] not in used_items + ] + if not available_items: continue - + # Find best item for this slot based on current needs best_item = None best_value = -1 - + for item in available_items: item_value = self._item_value(item, solution) - + # Bonus for items that help with our priority sets item_set = item.get("item_set_id") - if self.primary_set and item_set == self.primary_set and primary_count < 5: + if ( + self.primary_set + and item_set == self.primary_set + and primary_count < 5 + ): item_value += 2000 # Very high bonus for needed primary pieces - elif self.secondary_set and item_set == self.secondary_set and secondary_count < 4: + elif ( + self.secondary_set + and item_set == self.secondary_set + and secondary_count < 4 + ): item_value += 1500 # High bonus for needed secondary pieces - + if item_value > best_value: best_value = item_value best_item = item - + if best_item: solution["items"][slot] = best_item used_items.add(best_item["item_id"]) - + # Update counts for next iteration if best_item.get("item_set_id") == self.primary_set: primary_count += 1 elif best_item.get("item_set_id") == self.secondary_set: secondary_count += 1 - + def _solve_backtracking(self): """ Backtracking algorithm that explores solution space systematically. """ solution = {"items": {}, "stats": {}} used_items = set() - + # Create ordered list of (slot, constraints) for systematic search slot_constraints = self._create_slot_constraints() - + # Attempt backtracking search if self._backtrack_search(solution, used_items, slot_constraints, 0): return solution return None - + def _solve_random_restart(self): """ Random restart algorithm for exploring different parts of solution space. """ import random - + solution = {"items": {}, "stats": {}} used_items = set() - + # Randomly order slots and items for different exploration paths random_slots = self.all_slots.copy() random.shuffle(random_slots) - + for slot in random_slots: if slot not in self.items_by_slot: continue - - available_items = [item for item in self.items_by_slot[slot] - if item["item_id"] not in used_items] - + + available_items = [ + item + for item in self.items_by_slot[slot] + if item["item_id"] not in used_items + ] + if available_items: # Add some randomness to item selection while still preferring better items - weights = [self._item_value(item) + random.randint(0, 100) for item in available_items] + weights = [ + self._item_value(item) + random.randint(0, 100) + for item in available_items + ] max_weight = max(weights) - best_items = [item for i, item in enumerate(available_items) - if weights[i] >= max_weight * 0.8] # Top 20% with randomness - + best_items = [ + item + for i, item in enumerate(available_items) + if weights[i] >= max_weight * 0.8 + ] # Top 20% with randomness + selected_item = random.choice(best_items) solution["items"][slot] = selected_item used_items.add(selected_item["item_id"]) - + return solution if solution["items"] else None - + def _item_value(self, item, current_solution=None): """Calculate the value/priority of an item for constraint satisfaction.""" value = 0 - + # Get current set counts if solution provided primary_count = 0 secondary_count = 0 @@ -6250,126 +7399,164 @@ class ConstraintSatisfactionSolver: primary_count += 1 if self.secondary_set and existing_set == self.secondary_set: secondary_count += 1 - + # Dynamic set bonus value based on current needs item_set = item.get("item_set_id") if self.primary_set and item_set == self.primary_set: # Primary set priority decreases as we get closer to 5 pieces if primary_count < 5: - value += 1000 + (5 - primary_count) * 100 # Higher priority when we need more + value += ( + 1000 + (5 - primary_count) * 100 + ) # Higher priority when we need more else: value += 500 # Lower priority when we have enough - + if self.secondary_set and item_set == self.secondary_set: # Secondary set priority increases when primary is satisfied if secondary_count < 4: - if primary_count >= 4: # If primary is mostly satisfied, prioritize secondary + if ( + primary_count >= 4 + ): # If primary is mostly satisfied, prioritize secondary value += 1200 + (4 - secondary_count) * 150 # Very high priority else: - value += 800 + (4 - secondary_count) * 100 # High priority + value += 800 + (4 - secondary_count) * 100 # High priority else: value += 400 # Lower priority when we have enough - + # Spell bonus value item_spells = item.get("spell_names", []) for spell in self.required_spells: if spell in item_spells: value += 500 # High priority for required spells - + # Rating value value += item.get("armor_level", 0) value += item.get("crit_damage_rating", 0) * 10 value += item.get("damage_rating", 0) * 10 - + return value - + def _get_item_slots(self, item): """Get list of slots this item can be equipped to.""" return determine_item_slots(item) - + def _find_best_available_slot(self, possible_slots, current_items): """Find the best available slot from possible slots.""" for slot in possible_slots: if slot not in current_items: return slot return None - + def _should_replace_item(self, existing_item, new_item): """Determine if new item should replace existing item.""" existing_value = self._item_value(existing_item) new_value = self._item_value(new_item) return new_value > existing_value * 1.2 # 20% better to replace - + def _create_slot_constraints(self): """Create ordered list of slot constraints for backtracking.""" # This is a simplified version - full implementation would be more sophisticated return [(slot, []) for slot in self.all_slots] - + def _backtrack_search(self, solution, used_items, slot_constraints, slot_index): """Recursive backtracking search.""" if slot_index >= len(slot_constraints): return True # Found complete solution - + slot, constraints = slot_constraints[slot_index] - + if slot not in self.items_by_slot: - return self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1) - + return self._backtrack_search( + solution, used_items, slot_constraints, slot_index + 1 + ) + # Try each item for this slot - available_items = [item for item in self.items_by_slot[slot] - if item["item_id"] not in used_items] - + available_items = [ + item + for item in self.items_by_slot[slot] + if item["item_id"] not in used_items + ] + # Sort by value for better pruning available_items.sort(key=lambda x: self._item_value(x), reverse=True) - + for item in available_items[:5]: # Limit search to top 5 items per slot # Try placing this item solution["items"][slot] = item used_items.add(item["item_id"]) - + # Recursive search - if self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1): + if self._backtrack_search( + solution, used_items, slot_constraints, slot_index + 1 + ): return True - + # Backtrack del solution["items"][slot] used_items.remove(item["item_id"]) - + # Try leaving slot empty - return self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1) - + return self._backtrack_search( + solution, used_items, slot_constraints, slot_index + 1 + ) + def _is_unique_solution(self, solution, existing_solutions): """Check if solution is substantially different from existing ones.""" if not existing_solutions: return True - + solution_items = set(item["item_id"] for item in solution["items"].values()) - + for existing in existing_solutions: existing_items = set(item["item_id"] for item in existing["items"].values()) overlap = len(solution_items & existing_items) / max(len(solution_items), 1) if overlap > 0.7: # 70% overlap = too similar return False - + return True - + def _calculate_solution_stats(self, solution): """Calculate comprehensive statistics for solution.""" - calculate_suit_stats(solution, self.primary_set, self.secondary_set, self.required_spells) - + calculate_suit_stats( + solution, self.primary_set, self.secondary_set, self.required_spells + ) + def _calculate_solution_score(self, solution): """Calculate constraint satisfaction score for solution.""" - return calculate_suit_score(solution, self.primary_set, self.secondary_set, self.required_spells, - self.min_armor, self.min_crit_damage, self.min_damage_rating) - + return calculate_suit_score( + solution, + self.primary_set, + self.secondary_set, + self.required_spells, + self.min_armor, + self.min_crit_damage, + self.min_damage_rating, + ) + def _add_solution_analysis(self, solution): """Add analysis of what's missing or achieved.""" - add_suit_analysis(solution, self.primary_set, self.secondary_set, self.required_spells, - self.min_armor, self.min_crit_damage, self.min_damage_rating, self.min_heal_boost) + add_suit_analysis( + solution, + self.primary_set, + self.secondary_set, + self.required_spells, + self.min_armor, + self.min_crit_damage, + self.min_damage_rating, + self.min_heal_boost, + ) -def generate_optimal_suits(items_by_slot, primary_set, secondary_set, required_spells, - min_armor, min_crit_damage, min_damage_rating, max_results): +def generate_optimal_suits( + items_by_slot, + primary_set, + secondary_set, + required_spells, + min_armor, + min_crit_damage, + min_damage_rating, + max_results, +): """ Generate optimal equipment suit combinations using iterative constraint satisfaction. """ @@ -6377,30 +7564,44 @@ def generate_optimal_suits(items_by_slot, primary_set, secondary_set, required_s all_candidate_items = [] for slot_items in items_by_slot.values(): all_candidate_items.extend(slot_items) - + # Remove duplicates (same item might be valid for multiple slots) unique_items = {item["item_id"]: item for item in all_candidate_items} candidate_items = list(unique_items.values()) - + # Initialize constraint satisfaction solver - solver = ConstraintSatisfactionSolver(candidate_items, items_by_slot, - primary_set, secondary_set, required_spells, - min_armor, min_crit_damage, min_damage_rating) - + solver = ConstraintSatisfactionSolver( + candidate_items, + items_by_slot, + primary_set, + secondary_set, + required_spells, + min_armor, + min_crit_damage, + min_damage_rating, + ) + # Generate solutions using iterative constraint satisfaction suits = solver.solve(max_results, max_iterations=1000) - + return suits -def calculate_suit_score(suit, primary_set, secondary_set, required_spells, - min_armor, min_crit_damage, min_damage_rating): +def calculate_suit_score( + suit, + primary_set, + secondary_set, + required_spells, + min_armor, + min_crit_damage, + min_damage_rating, +): """ Calculate a score for how well a suit satisfies the constraints. """ score = 0 stats = suit["stats"] - + # Set bonus scoring (most important) if primary_set: primary_target = 5 @@ -6409,7 +7610,7 @@ def calculate_suit_score(suit, primary_set, secondary_set, required_spells, score += 40 # Full primary set bonus else: score += (primary_actual / primary_target) * 30 # Partial credit - + if secondary_set: secondary_target = 4 secondary_actual = stats["secondary_set_count"] @@ -6417,12 +7618,12 @@ def calculate_suit_score(suit, primary_set, secondary_set, required_spells, score += 30 # Full secondary set bonus else: score += (secondary_actual / secondary_target) * 20 # Partial credit - + # Required spells scoring if required_spells: spell_ratio = min(1.0, stats["required_spells_found"] / len(required_spells)) score += spell_ratio * 20 - + # Rating requirements scoring if min_armor and stats["total_armor"] >= min_armor: score += 5 @@ -6430,28 +7631,47 @@ def calculate_suit_score(suit, primary_set, secondary_set, required_spells, score += 5 if min_damage_rating and stats["total_damage_rating"] >= min_damage_rating: score += 5 - + return int(score) -def add_suit_analysis(suit, primary_set, secondary_set, required_spells, - min_armor=0, min_crit_damage=0, min_damage_rating=0, min_heal_boost=0): +def add_suit_analysis( + suit, + primary_set, + secondary_set, + required_spells, + min_armor=0, + min_crit_damage=0, + min_damage_rating=0, + min_heal_boost=0, +): """ Add comprehensive analysis of missing constraints and achievements in the suit. """ stats = suit["stats"] missing = [] notes = [] - + # Get set names for display set_names = { - 13: "Soldier's Set", 14: "Adept's Set", 16: "Defender's Set", 21: "Wise Set", - 40: "Heroic Protector", 41: "Heroic Destroyer", 46: "Relic Alduressa", - 47: "Ancient Relic", 48: "Noble Relic", 15: "Archer's Set", 19: "Hearty Set", - 20: "Dexterous Set", 22: "Swift Set", 24: "Reinforced Set", 26: "Flame Proof Set", - 29: "Lightning Proof Set" + 13: "Soldier's Set", + 14: "Adept's Set", + 16: "Defender's Set", + 21: "Wise Set", + 40: "Heroic Protector", + 41: "Heroic Destroyer", + 46: "Relic Alduressa", + 47: "Ancient Relic", + 48: "Noble Relic", + 15: "Archer's Set", + 19: "Hearty Set", + 20: "Dexterous Set", + 22: "Swift Set", + 24: "Reinforced Set", + 26: "Flame Proof Set", + 29: "Lightning Proof Set", } - + # Check primary set requirements if primary_set: primary_set_int = int(primary_set) @@ -6461,7 +7681,7 @@ def add_suit_analysis(suit, primary_set, secondary_set, required_spells, missing.append(f"Need {needed} more {set_name} pieces") else: notes.append(f"✅ {set_name} (5/5)") - + # Check secondary set requirements if secondary_set: secondary_set_int = int(secondary_set) @@ -6471,7 +7691,7 @@ def add_suit_analysis(suit, primary_set, secondary_set, required_spells, missing.append(f"Need {needed} more {set_name} pieces") else: notes.append(f"✅ {set_name} (4/4)") - + # Check legendary cantrips/spells requirements if required_spells: found = stats.get("required_spells_found", 0) @@ -6479,32 +7699,39 @@ def add_suit_analysis(suit, primary_set, secondary_set, required_spells, if found < total: missing_count = total - found missing_spells = [] - + # Determine which specific spells are missing suit_spells = set() for item in suit["items"].values(): if "spell_names" in item and item["spell_names"]: if isinstance(item["spell_names"], str): - suit_spells.update(spell.strip() for spell in item["spell_names"].split(",")) + suit_spells.update( + spell.strip() for spell in item["spell_names"].split(",") + ) elif isinstance(item["spell_names"], list): suit_spells.update(item["spell_names"]) - + for req_spell in required_spells: found_match = False for suit_spell in suit_spells: - if req_spell.lower() in suit_spell.lower() or suit_spell.lower() in req_spell.lower(): + if ( + req_spell.lower() in suit_spell.lower() + or suit_spell.lower() in req_spell.lower() + ): found_match = True break if not found_match: missing_spells.append(req_spell) - + if missing_spells: - missing.append(f"Missing: {', '.join(missing_spells[:3])}{'...' if len(missing_spells) > 3 else ''}") + missing.append( + f"Missing: {', '.join(missing_spells[:3])}{'...' if len(missing_spells) > 3 else ''}" + ) else: missing.append(f"Need {missing_count} more required spells") else: notes.append(f"✅ All {total} required spells found") - + # Check armor level requirements if min_armor > 0: current_armor = stats.get("total_armor", 0) @@ -6513,7 +7740,7 @@ def add_suit_analysis(suit, primary_set, secondary_set, required_spells, missing.append(f"Armor: {current_armor}/{min_armor} (-{shortfall})") else: notes.append(f"✅ Armor: {current_armor} (≥{min_armor})") - + # Check crit damage rating requirements if min_crit_damage > 0: current_crit = stats.get("total_crit_damage", 0) @@ -6522,50 +7749,77 @@ def add_suit_analysis(suit, primary_set, secondary_set, required_spells, missing.append(f"Crit Dmg: {current_crit}/{min_crit_damage} (-{shortfall})") else: notes.append(f"✅ Crit Dmg: {current_crit} (≥{min_crit_damage})") - + # Check damage rating requirements if min_damage_rating > 0: current_dmg = stats.get("total_damage_rating", 0) if current_dmg < min_damage_rating: shortfall = min_damage_rating - current_dmg - missing.append(f"Dmg Rating: {current_dmg}/{min_damage_rating} (-{shortfall})") + missing.append( + f"Dmg Rating: {current_dmg}/{min_damage_rating} (-{shortfall})" + ) else: notes.append(f"✅ Dmg Rating: {current_dmg} (≥{min_damage_rating})") - + # Check heal boost requirements if min_heal_boost > 0: current_heal = stats.get("total_heal_boost", 0) if current_heal < min_heal_boost: shortfall = min_heal_boost - current_heal - missing.append(f"Heal Boost: {current_heal}/{min_heal_boost} (-{shortfall})") + missing.append( + f"Heal Boost: {current_heal}/{min_heal_boost} (-{shortfall})" + ) else: notes.append(f"✅ Heal Boost: {current_heal} (≥{min_heal_boost})") - + # Add slot coverage analysis - armor_slots_filled = sum(1 for slot in ["Head", "Chest", "Upper Arms", "Lower Arms", "Hands", - "Abdomen", "Upper Legs", "Lower Legs", "Feet"] - if slot in suit["items"]) - jewelry_slots_filled = sum(1 for slot in ["Neck", "Left Ring", "Right Ring", - "Left Wrist", "Right Wrist", "Trinket"] - if slot in suit["items"]) - clothing_slots_filled = sum(1 for slot in ["Shirt", "Pants"] - if slot in suit["items"]) - + armor_slots_filled = sum( + 1 + for slot in [ + "Head", + "Chest", + "Upper Arms", + "Lower Arms", + "Hands", + "Abdomen", + "Upper Legs", + "Lower Legs", + "Feet", + ] + if slot in suit["items"] + ) + jewelry_slots_filled = sum( + 1 + for slot in [ + "Neck", + "Left Ring", + "Right Ring", + "Left Wrist", + "Right Wrist", + "Trinket", + ] + if slot in suit["items"] + ) + clothing_slots_filled = sum( + 1 for slot in ["Shirt", "Pants"] if slot in suit["items"] + ) + if armor_slots_filled < 9: missing.append(f"{9 - armor_slots_filled} armor slots empty") else: notes.append("✅ All 9 armor slots filled") - + if jewelry_slots_filled > 0: notes.append(f"📿 {jewelry_slots_filled}/6 jewelry slots filled") - + if clothing_slots_filled > 0: notes.append(f"👕 {clothing_slots_filled}/2 clothing slots filled") - + suit["missing"] = missing suit["notes"] = notes if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/static/script.js b/static/script.js index e8477268..dbb8b598 100644 --- a/static/script.js +++ b/static/script.js @@ -1484,6 +1484,108 @@ function renderInventoryState(state) { } state.itemGrid.appendChild(cell); } + + renderInventoryManaPanel(state); +} + +function getManaTrackedItems(state) { + if (!state || !state.items) return []; + + const snapshotMs = Date.now(); + return state.items + .filter(item => (item.current_wielded_location || 0) > 0) + .filter(item => item.is_mana_tracked || item.current_mana !== undefined || item.max_mana !== undefined || item.spellcraft !== undefined) + .map(item => { + const result = { ...item }; + if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) { + const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN; + if (!Number.isNaN(snapshotUtc)) { + const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000)); + result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0); + } else { + result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds; + } + } else { + result.live_mana_time_remaining_seconds = null; + } + return result; + }) + .sort((a, b) => { + const aRemaining = a.live_mana_time_remaining_seconds; + const bRemaining = b.live_mana_time_remaining_seconds; + if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || ''); + if (aRemaining === null) return 1; + if (bRemaining === null) return -1; + if (aRemaining !== bRemaining) return aRemaining - bRemaining; + return (a.name || '').localeCompare(b.name || ''); + }); +} + +function formatManaRemaining(totalSeconds) { + if (totalSeconds === null || totalSeconds === undefined) return '--'; + const safeSeconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + return `${hours}h${String(minutes).padStart(2, '0')}m`; +} + +function renderInventoryManaPanel(state) { + if (!state || !state.manaListBody || !state.manaSummary) return; + + const items = getManaTrackedItems(state); + state.manaListBody.innerHTML = ''; + + if (items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'inv-mana-empty'; + empty.textContent = 'No equipped mana-bearing items'; + state.manaListBody.appendChild(empty); + state.manaSummary.textContent = 'Mana: 0 tracked'; + return; + } + + const activeCount = items.filter(item => item.mana_state === 'active').length; + const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length; + state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`; + + items.forEach(item => { + const row = document.createElement('div'); + row.className = 'inv-mana-row'; + + const iconWrap = document.createElement('div'); + iconWrap.className = 'inv-mana-icon'; + iconWrap.appendChild(createInventorySlot(item)); + + const nameEl = document.createElement('div'); + nameEl.className = 'inv-mana-name'; + nameEl.textContent = item.name || item.Name || 'Unknown Item'; + + const stateEl = document.createElement('div'); + const stateName = item.mana_state || 'unknown'; + stateEl.className = `inv-mana-state mana-state-${stateName}`; + stateEl.textContent = stateName.replace(/_/g, ' '); + + const manaEl = document.createElement('div'); + manaEl.className = 'inv-mana-value'; + if (item.current_mana !== undefined && item.max_mana !== undefined) { + manaEl.textContent = `${item.current_mana} / ${item.max_mana}`; + } else if (item.mana_display) { + manaEl.textContent = item.mana_display; + } else { + manaEl.textContent = '--'; + } + + const timeEl = document.createElement('div'); + timeEl.className = 'inv-mana-time'; + timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds); + + row.appendChild(iconWrap); + row.appendChild(nameEl); + row.appendChild(stateEl); + row.appendChild(manaEl); + row.appendChild(timeEl); + state.manaListBody.appendChild(row); + }); } function showInventoryWindow(name) { @@ -1559,6 +1661,23 @@ function showInventoryWindow(name) { const bottomSection = document.createElement('div'); bottomSection.className = 'inv-bottom-section'; + + const itemSection = document.createElement('div'); + itemSection.className = 'inv-item-section'; + + const manaPanel = document.createElement('div'); + manaPanel.className = 'inv-mana-panel'; + const manaHeader = document.createElement('div'); + manaHeader.className = 'inv-mana-header'; + manaHeader.textContent = 'Mana'; + const manaSummary = document.createElement('div'); + manaSummary.className = 'inv-mana-summary'; + manaSummary.textContent = 'Mana: loading'; + const manaListBody = document.createElement('div'); + manaListBody.className = 'inv-mana-list'; + manaPanel.appendChild(manaHeader); + manaPanel.appendChild(manaSummary); + manaPanel.appendChild(manaListBody); const contentsHeader = document.createElement('div'); contentsHeader.className = 'inv-contents-header'; @@ -1567,8 +1686,10 @@ function showInventoryWindow(name) { const itemGrid = document.createElement('div'); itemGrid.className = 'inv-item-grid'; - bottomSection.appendChild(contentsHeader); - bottomSection.appendChild(itemGrid); + itemSection.appendChild(contentsHeader); + itemSection.appendChild(itemGrid); + bottomSection.appendChild(itemSection); + bottomSection.appendChild(manaPanel); invContent.appendChild(topSection); invContent.appendChild(bottomSection); @@ -1613,6 +1734,8 @@ function showInventoryWindow(name) { burdenFill: burdenFill, burdenLabel: burdenLabel, contentsHeader: contentsHeader, + manaSummary: manaSummary, + manaListBody: manaListBody, characterName: name }; diff --git a/static/style.css b/static/style.css index ef0ae281..5f013b55 100644 --- a/static/style.css +++ b/static/style.css @@ -908,11 +908,17 @@ body.noselect, body.noselect * { .inv-bottom-section { flex: 1; display: flex; - flex-direction: column; + flex-direction: row; margin-top: 10px; margin-right: 52px; overflow: hidden; min-height: 0; + gap: 8px; +} + +.inv-bottom-section > :first-child, +.inv-bottom-section > :nth-child(2) { + flex-shrink: 0; } .inv-contents-header { @@ -924,6 +930,12 @@ body.noselect, body.noselect * { padding-bottom: 2px; } +.inv-item-section { + display: flex; + flex-direction: column; + min-width: 0; +} + .inv-item-grid { display: grid; grid-template-columns: repeat(6, 36px); @@ -939,6 +951,110 @@ body.noselect, body.noselect * { justify-content: start; } +.inv-mana-panel { + width: 118px; + min-width: 118px; + display: flex; + flex-direction: column; + background: rgba(6, 10, 18, 0.92); + border: 1px solid var(--ac-border-light); + padding: 4px; + min-height: 0; +} + +.inv-mana-header { + color: var(--ac-gold); + font-size: 14px; + text-align: center; + border-bottom: 1px solid var(--ac-border-light); + padding-bottom: 2px; +} + +.inv-mana-summary { + color: var(--ac-text-dim); + font-size: 10px; + line-height: 1.2; + padding: 4px 0; + border-bottom: 1px solid rgba(255,255,255,0.08); + margin-bottom: 4px; +} + +.inv-mana-list { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +.inv-mana-row { + display: grid; + grid-template-columns: 36px 1fr; + grid-template-rows: auto auto auto; + gap: 1px 6px; + align-items: center; + background: rgba(18, 24, 34, 0.9); + border: 1px solid rgba(255,255,255,0.08); + padding: 3px; +} + +.inv-mana-icon { + grid-row: 1 / span 3; + width: 36px; + height: 36px; +} + +.inv-mana-icon .inventory-slot { + width: 36px; + height: 36px; +} + +.inv-mana-name { + color: #f2e6c9; + font-size: 10px; + line-height: 1.15; + word-break: break-word; +} + +.inv-mana-state, +.inv-mana-value, +.inv-mana-time { + font-size: 10px; + line-height: 1.1; +} + +.inv-mana-value { + color: #98d7ff; +} + +.inv-mana-time { + color: #cfe6a0; +} + +.mana-state-active { + color: #76d17f; +} + +.mana-state-not_active { + color: #ff8e6f; +} + +.mana-state-unknown { + color: #d4c27a; +} + +.mana-state-not_activatable { + color: #97a1ad; +} + +.inv-mana-empty { + color: var(--ac-text-dim); + font-size: 11px; + text-align: center; + padding: 12px 6px; +} + .inv-item-grid::-webkit-scrollbar { width: 12px; }