Adjust the inventory mana panel to fit beside the backpack column without overlap, prevent the panel from scrolling, shrink composite icons correctly, and refine mana-state derivation using existing item spell data.
7906 lines
316 KiB
Python
7906 lines
316 KiB
Python
"""
|
|
Inventory Service - Separate microservice for item data processing and queries.
|
|
|
|
Handles enum translation, data normalization, and provides structured item data APIs.
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import time
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Any
|
|
from datetime import datetime
|
|
|
|
from fastapi import FastAPI, HTTPException, Depends, Query, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from sse_starlette.sse import EventSourceResponse
|
|
from pydantic import BaseModel
|
|
import databases
|
|
import sqlalchemy as sa
|
|
|
|
from database import (
|
|
Base,
|
|
Item,
|
|
ItemCombatStats,
|
|
ItemRequirements,
|
|
ItemEnhancements,
|
|
ItemRatings,
|
|
ItemSpells,
|
|
ItemRawData,
|
|
DATABASE_URL,
|
|
create_indexes,
|
|
)
|
|
|
|
# Import helpers to share enum mappings
|
|
import helpers
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
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:
|
|
logger.warning(f"Suitbuilder module not available: {e}")
|
|
SUITBUILDER_AVAILABLE = False
|
|
set_database_connection = None
|
|
|
|
# FastAPI app with comprehensive OpenAPI documentation
|
|
app = FastAPI(
|
|
title="Inventory Service API",
|
|
description="""
|
|
## Comprehensive Inventory Management API for Asheron's Call
|
|
|
|
This service provides powerful search and data processing capabilities for game item inventories.
|
|
|
|
### Key Features
|
|
- **Item Search**: Advanced filtering across 40+ parameters including spells, stats, sets, and more
|
|
- **Character Management**: Multi-character inventory tracking and processing
|
|
- **Equipment Optimization**: Integration with suitbuilder for optimal equipment combinations
|
|
- **Real-time Processing**: Live inventory data ingestion and normalization
|
|
- **Comprehensive Filtering**: Support for armor, jewelry, weapons, clothing, and spell-based searches
|
|
|
|
### Main Endpoints
|
|
- `/search/items` - Advanced item search with extensive filtering options
|
|
- `/process-inventory` - Process and normalize raw inventory data
|
|
- `/inventory/{character}` - Get character-specific inventory data
|
|
- `/suitbuilder/search` - Equipment optimization and suit building
|
|
|
|
### Search Parameters
|
|
The search endpoint supports comprehensive filtering including:
|
|
- **Text Search**: Item names, descriptions, properties
|
|
- **Character Filtering**: Specific characters or cross-character search
|
|
- **Equipment Status**: Equipped, unequipped, or all items
|
|
- **Item Categories**: Armor, jewelry, weapons, clothing filters
|
|
- **Combat Properties**: Damage, armor, attack bonuses, ratings
|
|
- **Spell Filtering**: Specific spells, cantrips, legendary effects
|
|
- **Requirements**: Level, skill requirements
|
|
- **Enhancements**: Materials, workmanship, item sets, tinkering
|
|
- **Item State**: Bonded, attuned, condition, rarity
|
|
- **Sorting & Pagination**: Flexible result ordering and pagination
|
|
""",
|
|
version="1.0.0",
|
|
contact={
|
|
"name": "Dereth Tracker",
|
|
"url": "https://github.com/your-repo/dereth-tracker",
|
|
},
|
|
license_info={
|
|
"name": "MIT",
|
|
},
|
|
)
|
|
|
|
# Configure CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # Allow all origins for development
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Include suitbuilder router
|
|
if SUITBUILDER_AVAILABLE:
|
|
app.include_router(suitbuilder_router, prefix="/suitbuilder", tags=["suitbuilder"])
|
|
logger.info("Suitbuilder endpoints included at /suitbuilder")
|
|
|
|
# Database connection
|
|
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."""
|
|
logger.info("Loading comprehensive enum database...")
|
|
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:
|
|
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:
|
|
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": {},
|
|
}
|
|
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": {},
|
|
}
|
|
|
|
# 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", {})
|
|
|
|
# Convert IntValueKey to integer-keyed dict for fast lookup
|
|
int_values = {}
|
|
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():
|
|
try:
|
|
materials[int(k)] = v
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Item types mapping
|
|
item_types = {}
|
|
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"]
|
|
for k, v in skill_data.items():
|
|
try:
|
|
if isinstance(v, dict):
|
|
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():
|
|
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():
|
|
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():
|
|
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()}
|
|
|
|
# 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():
|
|
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():
|
|
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"
|
|
)
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
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
|
|
value: int
|
|
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": {
|
|
"items": [
|
|
{
|
|
"name": "Gold Celdon Girth",
|
|
"character_name": "Megamula XXXIII",
|
|
"armor_level": 320,
|
|
"crit_damage_rating": 2,
|
|
"set_name": "Soldier's",
|
|
"material_name": "Gold",
|
|
"equipped": False,
|
|
"spells": ["Legendary Strength", "Legendary Endurance"],
|
|
}
|
|
],
|
|
"total_count": 1247,
|
|
"page": 1,
|
|
"limit": 200,
|
|
"has_next": True,
|
|
"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": {
|
|
"processed_count": 186,
|
|
"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",
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
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},
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
|
|
# 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")}
|
|
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")
|
|
)
|
|
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")
|
|
)
|
|
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", {})
|
|
|
|
for key_str, value in int_values.items():
|
|
try:
|
|
key_int = int(key_str)
|
|
if key_int in int_enum_map:
|
|
enum_name = int_enum_map[key_int]
|
|
translated[enum_name] = value
|
|
else:
|
|
# Keep unknown keys with numeric identifier
|
|
translated[f"unknown_{key_int}"] = value
|
|
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", {})
|
|
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", {})
|
|
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:
|
|
"""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", {})
|
|
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", {})
|
|
set_name = attribute_set_info.get(str(set_id))
|
|
if set_name:
|
|
return set_name
|
|
else:
|
|
# 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", {})
|
|
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()
|
|
|
|
# Mana stones and crystals
|
|
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:
|
|
return "Crystal"
|
|
else:
|
|
return "Gem"
|
|
|
|
# Aetheria detection - check for specific properties
|
|
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:
|
|
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"]
|
|
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", {})
|
|
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", {})
|
|
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", {})
|
|
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", ""),
|
|
}
|
|
else:
|
|
return {
|
|
"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", {})
|
|
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()
|
|
)
|
|
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",
|
|
}
|
|
|
|
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
|
|
while value != 0:
|
|
if (value & 1) == 1:
|
|
bits_set += 1
|
|
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
|
|
|
|
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
|
|
):
|
|
options.append(coverage_value)
|
|
else:
|
|
# Implement Mag-SuitBuilder reduction logic
|
|
if coverage_value == (OuterwearUpperArms | OuterwearLowerArms):
|
|
options.extend([OuterwearUpperArms, OuterwearLowerArms])
|
|
elif coverage_value == (OuterwearUpperLegs | OuterwearLowerLegs):
|
|
options.extend([OuterwearUpperLegs, OuterwearLowerLegs])
|
|
elif coverage_value == (OuterwearLowerLegs | Feet):
|
|
options.append(Feet)
|
|
elif coverage_value == (OuterwearChest | OuterwearAbdomen):
|
|
options.append(OuterwearChest)
|
|
elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms):
|
|
options.append(OuterwearChest)
|
|
elif coverage_value == (
|
|
OuterwearChest | OuterwearUpperArms | OuterwearLowerArms
|
|
):
|
|
options.append(OuterwearChest)
|
|
elif coverage_value == (OuterwearChest | OuterwearUpperArms):
|
|
options.append(OuterwearChest)
|
|
elif coverage_value == (
|
|
OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs
|
|
):
|
|
options.extend([OuterwearAbdomen, OuterwearUpperLegs, OuterwearLowerLegs])
|
|
elif coverage_value == (
|
|
OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms
|
|
):
|
|
options.append(OuterwearChest)
|
|
elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs):
|
|
# Pre-2010 retail guidelines - assume abdomen reduction only
|
|
options.append(OuterwearAbdomen)
|
|
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
|
|
}
|
|
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:
|
|
"""
|
|
Get sophisticated slot options using Mag-SuitBuilder logic.
|
|
This handles armor reduction for tailorable pieces.
|
|
"""
|
|
# Special case: shoes that cover feet + lower legs but only go in feet slot
|
|
LowerLegWear = 128
|
|
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 not has_material:
|
|
# Can't reduce non-loot gen pieces, return all slots
|
|
return [equippable_slots]
|
|
else:
|
|
# Use coverage reduction options
|
|
reduction_options = get_coverage_reduction_options(coverage_value)
|
|
slot_options = []
|
|
for option in reduction_options:
|
|
equip_slot = coverage_to_equip_mask(option)
|
|
slot_options.append(equip_slot)
|
|
return slot_options if slot_options else [equippable_slots]
|
|
else:
|
|
# 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",
|
|
}
|
|
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"]
|
|
for k, v in equip_data.items():
|
|
try:
|
|
# Skip expression values (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",
|
|
}
|
|
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",
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
translated_parts = [name_mapping.get(part, part) for part in slot_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:
|
|
return "Aetheria Blue"
|
|
elif wielded_location == 536870912:
|
|
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:
|
|
return ""
|
|
elif workmanship_value == 1:
|
|
return "Pitiful (1)"
|
|
elif workmanship_value == 2:
|
|
return "Poor (2)"
|
|
elif workmanship_value == 3:
|
|
return "Below Average (3)"
|
|
elif workmanship_value == 4:
|
|
return "Average (4)"
|
|
elif workmanship_value == 5:
|
|
return "Above Average (5)"
|
|
elif workmanship_value == 6:
|
|
return "Nearly flawless (6)"
|
|
elif workmanship_value == 7:
|
|
return "Flawless (7)"
|
|
elif workmanship_value >= 8:
|
|
return "Utterly flawless (8)"
|
|
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"
|
|
elif armor_level < 300:
|
|
category = "Below Average"
|
|
elif armor_level < 400:
|
|
category = "Average"
|
|
elif armor_level < 500:
|
|
category = "Above Average"
|
|
elif armor_level < 600:
|
|
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", {})
|
|
|
|
# Max damage
|
|
max_damage = None
|
|
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"]
|
|
elif 167772171 in double_values:
|
|
variance = double_values[167772171]
|
|
|
|
# Damage bonus
|
|
damage_bonus = None
|
|
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
|
|
elif 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"
|
|
else:
|
|
# Check for elemental damage bonus
|
|
elemental_bonus = None
|
|
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"
|
|
else:
|
|
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", {})
|
|
|
|
# Weapon speed (check multiple possible keys)
|
|
speed_value = None
|
|
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 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})"
|
|
elif speed_value <= 30:
|
|
speed_info["speed_text"] = f"Fast ({speed_value})"
|
|
elif speed_value <= 40:
|
|
speed_info["speed_text"] = f"Average ({speed_value})"
|
|
elif speed_value <= 50:
|
|
speed_info["speed_text"] = f"Slow ({speed_value})"
|
|
else:
|
|
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", {})
|
|
|
|
# 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"]
|
|
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"]
|
|
elif 72 in int_values:
|
|
max_mana = int_values[72]
|
|
|
|
# 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"]
|
|
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
|
|
|
|
if 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 []
|
|
active_spells = item_data.get("ActiveSpells", []) or []
|
|
active_item_enchantments = item_data.get("ActiveItemEnchantments", []) 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]
|
|
|
|
def is_spell_active(spell_data: Dict[str, Any]) -> bool:
|
|
if not spell_data:
|
|
return False
|
|
|
|
spell_id = spell_data.get("id")
|
|
if spell_id in active_spells:
|
|
return True
|
|
|
|
spell_family = spell_data.get("family")
|
|
spell_difficulty = spell_data.get("difficulty")
|
|
if spell_family in (None, "", 0):
|
|
return False
|
|
|
|
for active_spell in active_item_enchantments:
|
|
if not active_spell:
|
|
continue
|
|
if active_spell.get("family") != spell_family:
|
|
continue
|
|
|
|
active_difficulty = active_spell.get("difficulty")
|
|
if active_difficulty in (None, "") or spell_difficulty in (None, ""):
|
|
return True
|
|
|
|
try:
|
|
if int(active_difficulty) >= int(spell_difficulty):
|
|
return True
|
|
except (TypeError, ValueError):
|
|
return True
|
|
|
|
return False
|
|
|
|
translated_spells = [translate_spell(spell_id) for spell_id in spell_ids]
|
|
actionable_spells = []
|
|
for spell in translated_spells:
|
|
if not spell:
|
|
continue
|
|
if spell.get("id") == int_values.get("94") or spell.get("id") == int_values.get(
|
|
94
|
|
):
|
|
continue
|
|
spell_name = (spell.get("name") or "").lower()
|
|
if spell_name.startswith("unknown_spell_"):
|
|
continue
|
|
if spell_name.startswith(("cantrip portal send", "cantrip portal recall")):
|
|
continue
|
|
if spell_name.startswith(("incantation of ", "aura of incantation ")):
|
|
actionable_spells.append(spell)
|
|
continue
|
|
if spell_name.startswith(
|
|
(
|
|
"feeble ",
|
|
"minor ",
|
|
"lesser ",
|
|
"moderate ",
|
|
"inner ",
|
|
"major ",
|
|
"epic ",
|
|
"legendary ",
|
|
"prodigal ",
|
|
)
|
|
):
|
|
actionable_spells.append(spell)
|
|
continue
|
|
duration = spell.get("duration")
|
|
try:
|
|
if duration is not None and int(duration) <= 0:
|
|
actionable_spells.append(spell)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
has_inactive_spell = any(not is_spell_active(spell) for spell in actionable_spells)
|
|
|
|
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"
|
|
elif has_inactive_spell:
|
|
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", {})
|
|
if int_values:
|
|
translations["int_properties"] = translate_int_values(int_values)
|
|
|
|
# Translate material if present (check multiple locations)
|
|
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"]
|
|
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
|
|
|
|
# Translate item type if present (check ItemType field or IntValues[1])
|
|
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", {})
|
|
if isinstance(int_values, dict):
|
|
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)
|
|
else:
|
|
# Fallback: derive ItemType from 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
|
|
)
|
|
|
|
# Translate object class using WeenieType enum
|
|
object_class = item_data.get("ObjectClass")
|
|
if object_class is not None:
|
|
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", {})
|
|
|
|
# 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),
|
|
# Equipment status - handle string keys properly
|
|
"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))),
|
|
# 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)),
|
|
# Durability
|
|
"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)),
|
|
},
|
|
"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)
|
|
),
|
|
# 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)),
|
|
# 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)),
|
|
# 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)),
|
|
# 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)
|
|
),
|
|
},
|
|
"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
|
|
# 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)),
|
|
# 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)),
|
|
# Set information
|
|
"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)),
|
|
# Cooldowns
|
|
"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
|
|
# 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
|
|
# 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
|
|
# 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
|
|
# 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)),
|
|
# 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)),
|
|
},
|
|
}
|
|
|
|
# Handle material field properly - check if already translated or needs translation
|
|
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
|
|
else:
|
|
# Material needs translation from IntValues[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
|
|
|
|
# Translate item_set ID to name for database storage
|
|
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", {})
|
|
set_name = attribute_set_info.get(str(item_set_id))
|
|
if 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)
|
|
|
|
# Get comprehensive translations
|
|
translations = get_comprehensive_translations(item_data)
|
|
if translations:
|
|
properties["translations"] = translations
|
|
|
|
# Translate raw enum values if available
|
|
int_values = item_data.get("IntValues", {})
|
|
if int_values:
|
|
translated_ints = translate_int_values(int_values)
|
|
properties["translated_ints"] = translated_ints
|
|
|
|
# Extract spell information
|
|
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", {})
|
|
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,
|
|
}
|
|
|
|
# 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
|
|
elif key_int == 106: # ItemSpellcraft
|
|
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
|
|
|
|
# Add weapon-specific information
|
|
damage_info = get_damage_range_and_type(item_data)
|
|
if damage_info:
|
|
properties["weapon_damage"] = damage_info
|
|
|
|
speed_info = get_weapon_speed(item_data)
|
|
if speed_info:
|
|
properties["weapon_speed"] = speed_info
|
|
|
|
mana_info = get_mana_and_spellcraft(item_data)
|
|
if mana_info:
|
|
properties["mana_info"] = mana_info
|
|
|
|
return properties
|
|
|
|
|
|
# API endpoints
|
|
@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
|
|
into normalized database tables with comprehensive enum translation and validation.
|
|
|
|
### Processing Steps:
|
|
1. **Data Validation**: Validate incoming JSON structure and required fields
|
|
2. **Enum Translation**: Convert game IDs to human-readable names using comprehensive enum database
|
|
3. **Normalization**: Split item data into related tables (combat stats, spells, enhancements, etc.)
|
|
4. **Error Handling**: Track and report any processing errors with detailed error messages
|
|
5. **Statistics**: Return comprehensive processing statistics
|
|
|
|
### Database Schema:
|
|
- **items**: Core item properties (name, icon, value, etc.)
|
|
- **item_combat_stats**: Armor level, damage bonuses, attack ratings
|
|
- **item_enhancements**: Material, workmanship, item sets, tinkering
|
|
- **item_spells**: Spell names and categories
|
|
- **item_requirements**: Level and skill requirements
|
|
- **item_ratings**: All rating values (crit damage, damage resist, etc.)
|
|
- **item_raw_data**: Original JSON for complex queries
|
|
|
|
### Error Handling:
|
|
Returns detailed error information for items that fail to process,
|
|
including SQL type errors, missing fields, and validation failures.
|
|
""",
|
|
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}
|
|
)
|
|
|
|
if 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",
|
|
):
|
|
await database.execute(
|
|
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},
|
|
)
|
|
|
|
# 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")
|
|
if item_id is None:
|
|
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"]
|
|
|
|
# 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),
|
|
# 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)
|
|
)
|
|
|
|
result = await database.fetch_one(item_stmt)
|
|
db_item_id = result["id"]
|
|
|
|
# Store combat stats if applicable
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
await database.execute(enh_stmt)
|
|
|
|
# Store ratings if applicable
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
await database.execute(rat_stmt)
|
|
|
|
# Store spell data if applicable
|
|
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},
|
|
)
|
|
|
|
# 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()
|
|
)
|
|
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,
|
|
),
|
|
)
|
|
)
|
|
await database.execute(raw_stmt)
|
|
|
|
processed_count += 1
|
|
|
|
except Exception as 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"
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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")
|
|
if item_game_id is None:
|
|
raise HTTPException(
|
|
status_code=400, detail="Item must have an 'Id' or 'id' field"
|
|
)
|
|
|
|
processed_count = 0
|
|
error_count = 0
|
|
|
|
async with database.transaction():
|
|
# 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},
|
|
)
|
|
|
|
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",
|
|
):
|
|
await database.execute(
|
|
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},
|
|
)
|
|
|
|
# Process and insert the single item using the same logic as process_inventory
|
|
try:
|
|
properties = extract_item_properties(item)
|
|
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)
|
|
)
|
|
|
|
result = await database.fetch_one(item_stmt)
|
|
db_item_id = result["id"]
|
|
|
|
# Store combat stats if applicable
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
await database.execute(enh_stmt)
|
|
|
|
# Store ratings if applicable
|
|
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()
|
|
}
|
|
),
|
|
)
|
|
)
|
|
await database.execute(rat_stmt)
|
|
|
|
# Store spell data if applicable
|
|
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},
|
|
)
|
|
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()
|
|
)
|
|
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,
|
|
),
|
|
)
|
|
)
|
|
await database.execute(raw_stmt)
|
|
|
|
processed_count = 1
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error processing item {item_game_id}: {e}"
|
|
logger.error(error_msg)
|
|
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}"
|
|
)
|
|
|
|
# Fetch the just-upserted item with all joins and enrich it
|
|
enriched_item = None
|
|
try:
|
|
enrich_query = """
|
|
SELECT
|
|
i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden,
|
|
i.has_id_data, i.timestamp,
|
|
i.current_wielded_location, i.container_id, i.items_capacity,
|
|
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
|
|
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
|
|
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
|
|
rt.damage_rating, rt.crit_rating, rt.heal_boost_rating,
|
|
rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_requirements r ON i.id = r.item_id
|
|
LEFT JOIN item_enhancements e ON i.id = e.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
|
|
WHERE i.id = :db_id
|
|
"""
|
|
row = await database.fetch_one(enrich_query, {"db_id": db_item_id})
|
|
if row:
|
|
enriched_item = enrich_db_item(row)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to enrich item after upsert: {e}")
|
|
|
|
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"],
|
|
)
|
|
async def delete_inventory_item(character_name: str, item_id: int):
|
|
"""Delete a single item from a character's inventory."""
|
|
|
|
deleted_count = 0
|
|
|
|
async with database.transaction():
|
|
# 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},
|
|
)
|
|
|
|
if 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",
|
|
):
|
|
await database.execute(
|
|
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},
|
|
)
|
|
|
|
deleted_count = len(existing)
|
|
|
|
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"]
|
|
# 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:
|
|
if processed_item.get("active_item_enchantments"):
|
|
original_json["ActiveItemEnchantments"] = processed_item[
|
|
"active_item_enchantments"
|
|
]
|
|
|
|
# Extract properties and get translations
|
|
properties = extract_item_properties(original_json)
|
|
|
|
# Add translated properties to the item
|
|
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"]
|
|
else:
|
|
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
|
|
)
|
|
|
|
# Add skill translation
|
|
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")
|
|
|
|
# Move spells to top level
|
|
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"]
|
|
|
|
# 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"]
|
|
|
|
# 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"]
|
|
|
|
# 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") 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"]
|
|
|
|
# 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"
|
|
]
|
|
|
|
# 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"
|
|
]
|
|
|
|
# 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"]
|
|
# 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"
|
|
]
|
|
|
|
# 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"]
|
|
|
|
# Add comprehensive enhancements
|
|
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"]
|
|
|
|
# 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"]
|
|
# 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", {}
|
|
)
|
|
if set_id in attribute_set_info:
|
|
processed_item["item_set_name"] = attribute_set_info[set_id]
|
|
else:
|
|
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"]
|
|
|
|
# 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"]
|
|
|
|
# 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"]
|
|
|
|
# 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"]
|
|
|
|
# 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"]
|
|
|
|
# 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"]
|
|
|
|
# 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
|
|
)
|
|
|
|
# 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)
|
|
|
|
# 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"],
|
|
)
|
|
async def get_character_inventory(
|
|
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,
|
|
i.has_id_data, i.timestamp,
|
|
i.current_wielded_location, i.container_id, i.items_capacity,
|
|
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
|
|
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
|
|
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
|
|
rt.damage_rating, rt.crit_rating, rt.heal_boost_rating,
|
|
rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_requirements r ON i.id = r.item_id
|
|
LEFT JOIN item_enhancements e ON i.id = e.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
|
|
WHERE i.character_name = :character_name
|
|
ORDER BY i.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}"
|
|
)
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
@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:
|
|
# Test database connectivity
|
|
await database.fetch_one("SELECT 1")
|
|
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",
|
|
)
|
|
|
|
|
|
@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
|
|
the number of items available for each set. Useful for understanding
|
|
available equipment options and set coverage.
|
|
|
|
### Common Set IDs:
|
|
- **13**: Soldier's (combat-focused armor)
|
|
- **14**: Adept's (magical armor)
|
|
- **42**: Hearty (health/vitality focused)
|
|
- **21**: Wise (mana-focused)
|
|
- **40**: Heroic Protector
|
|
- **41**: Heroic Destroyer
|
|
""",
|
|
tags=["Equipment Sets"],
|
|
)
|
|
async def list_equipment_sets():
|
|
"""Get all unique equipment set names with item counts from the database."""
|
|
try:
|
|
# Get equipment set IDs (the numeric collection sets)
|
|
query = """
|
|
SELECT DISTINCT enh.item_set, COUNT(*) as item_count
|
|
FROM item_enhancements enh
|
|
WHERE enh.item_set IS NOT NULL AND enh.item_set != ''
|
|
GROUP BY enh.item_set
|
|
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", {})
|
|
|
|
# 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)
|
|
|
|
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"],
|
|
)
|
|
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"),
|
|
}
|
|
|
|
|
|
@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":
|
|
return {"translation": translate_item_type(value)}
|
|
elif enum_type == "skill":
|
|
return {"translation": translate_skill(value)}
|
|
elif enum_type == "spell_category":
|
|
return {"translation": translate_spell_category(value)}
|
|
elif enum_type == "spell":
|
|
return {"translation": translate_spell(value)}
|
|
elif enum_type == "int_value":
|
|
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,
|
|
rd.int_values, rd.double_values, rd.string_values, rd.bool_values,
|
|
rd.original_json
|
|
FROM items i
|
|
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
|
|
WHERE i.character_name = :character_name
|
|
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}"
|
|
)
|
|
|
|
# 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"]
|
|
# 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):
|
|
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
|
|
|
|
processed_items.append(item_dict)
|
|
|
|
return {
|
|
"character_name": character_name,
|
|
"item_count": len(processed_items),
|
|
"items": processed_items,
|
|
}
|
|
|
|
|
|
# ===================================================================
|
|
# INVENTORY SEARCH API ENDPOINTS
|
|
# ===================================================================
|
|
|
|
|
|
@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
|
|
with support for 40+ filter parameters including text search, equipment categories,
|
|
combat properties, spells, requirements, and more.
|
|
|
|
### Example Searches:
|
|
- **Basic Text Search**: `?text=Celdon` - Find all items with "Celdon" in name
|
|
- **Character Specific**: `?character=Megamula%20XXXIII` - Items from one character
|
|
- **Multi-Character**: `?characters=Char1,Char2,Char3` - Items from specific characters
|
|
- **Set-Based**: `?item_set=13&min_crit_damage_rating=2` - Soldier's set with CD2+
|
|
- **Spell Search**: `?legendary_cantrips=Legendary%20Strength,Legendary%20Endurance`
|
|
- **Category Filter**: `?armor_only=true&min_armor=300` - High-level armor only
|
|
- **Equipment Status**: `?equipment_status=unequipped` - Only inventory items
|
|
|
|
### Advanced Filtering:
|
|
- **Combat Properties**: Filter by damage, armor, attack bonuses, ratings
|
|
- **Spell Effects**: Search for specific cantrips, wards, or legendary effects
|
|
- **Item Categories**: Armor, jewelry, weapons, clothing with sub-categories
|
|
- **Enhancement Data**: Material types, workmanship, item sets, tinkering
|
|
- **Requirements**: Level, skill requirements for wielding
|
|
- **Item State**: Bonded, attuned, condition, rarity filters
|
|
|
|
### Response Format:
|
|
Returns paginated results with item details including translated properties,
|
|
combat stats, spell information, and character ownership data.
|
|
""",
|
|
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",
|
|
),
|
|
# 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)"
|
|
),
|
|
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)"
|
|
),
|
|
# 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",
|
|
),
|
|
# 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_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_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_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_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"
|
|
),
|
|
# 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
|
|
),
|
|
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"
|
|
),
|
|
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_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"),
|
|
):
|
|
"""
|
|
Search items across characters with comprehensive filtering options.
|
|
"""
|
|
try:
|
|
# Initialize underwear filter type
|
|
underwear_filter_type = None
|
|
if shirt_only:
|
|
underwear_filter_type = "shirts"
|
|
elif pants_only:
|
|
underwear_filter_type = "pants"
|
|
elif underwear_only:
|
|
underwear_filter_type = "all_underwear"
|
|
|
|
# Build base query with CTE for computed slot names
|
|
query_parts = [
|
|
"""
|
|
WITH items_with_slots AS (
|
|
SELECT DISTINCT
|
|
i.id as db_item_id,
|
|
i.character_name,
|
|
i.name,
|
|
i.icon,
|
|
i.object_class,
|
|
i.value,
|
|
i.burden,
|
|
i.current_wielded_location,
|
|
i.bonded,
|
|
i.attuned,
|
|
i.unique,
|
|
i.stack_size,
|
|
i.max_stack_size,
|
|
i.structure,
|
|
i.max_structure,
|
|
i.rare_id,
|
|
i.timestamp as last_updated,
|
|
COALESCE(cs.max_damage, -1) as max_damage,
|
|
COALESCE(cs.armor_level, -1) as armor_level,
|
|
COALESCE(cs.attack_bonus, -1.0) as attack_bonus,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'314')::int, -1),
|
|
COALESCE((rd.int_values->>'374')::int, -1)
|
|
) as crit_damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'307')::int, -1),
|
|
COALESCE((rd.int_values->>'370')::int, -1)
|
|
) as damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'323')::int, -1),
|
|
COALESCE((rd.int_values->>'376')::int, -1)
|
|
) as heal_boost_rating,
|
|
COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'308')::int, -1),
|
|
COALESCE((rd.int_values->>'371')::int, -1)
|
|
) as damage_resist_rating,
|
|
COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'316')::int, -1),
|
|
COALESCE((rd.int_values->>'375')::int, -1)
|
|
) as crit_damage_resist_rating,
|
|
COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating,
|
|
COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating,
|
|
COALESCE((rd.int_values->>'342')::int, -1) as healing_rating,
|
|
COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating,
|
|
COALESCE((rd.int_values->>'351')::int, -1) as life_resist_rating,
|
|
COALESCE((rd.int_values->>'356')::int, -1) as sneak_attack_rating,
|
|
COALESCE((rd.int_values->>'357')::int, -1) as recklessness_rating,
|
|
COALESCE((rd.int_values->>'358')::int, -1) as deception_rating,
|
|
COALESCE((rd.int_values->>'381')::int, -1) as pk_damage_rating,
|
|
COALESCE((rd.int_values->>'382')::int, -1) as pk_damage_resist_rating,
|
|
COALESCE((rd.int_values->>'383')::int, -1) as gear_pk_damage_rating,
|
|
COALESCE((rd.int_values->>'384')::int, -1) as gear_pk_damage_resist_rating,
|
|
COALESCE(req.wield_level, -1) as wield_level,
|
|
COALESCE(enh.material, '') as material,
|
|
COALESCE(enh.workmanship, -1.0) as workmanship,
|
|
COALESCE(enh.imbue, '') as imbue,
|
|
COALESCE(enh.tinks, -1) as tinks,
|
|
COALESCE(enh.item_set, '') as item_set,
|
|
rd.original_json,
|
|
COALESCE((rd.int_values->>'218103821')::int, 0) as coverage_mask,
|
|
|
|
-- Compute slot_name in SQL
|
|
CASE
|
|
-- ARMOR/CLOTHING: Use EquipableSlots_Decal from JSON
|
|
WHEN rd.original_json IS NOT NULL
|
|
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
|
|
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
|
|
THEN
|
|
-- Translate equippable slots to readable names
|
|
CASE (rd.original_json->'IntValues'->>'218103822')::int
|
|
WHEN 1 THEN 'Head'
|
|
WHEN 2 THEN 'Neck'
|
|
WHEN 4 THEN 'Shirt'
|
|
WHEN 16 THEN 'Chest'
|
|
WHEN 32 THEN 'Hands'
|
|
WHEN 256 THEN 'Feet'
|
|
WHEN 512 THEN 'Chest'
|
|
WHEN 1024 THEN 'Abdomen'
|
|
WHEN 2048 THEN 'Upper Arms'
|
|
WHEN 4096 THEN 'Lower Arms'
|
|
WHEN 8192 THEN 'Upper Legs'
|
|
WHEN 16384 THEN 'Lower Legs'
|
|
WHEN 33554432 THEN 'Shield'
|
|
-- Multi-slot combinations
|
|
WHEN 15 THEN 'Chest, Abdomen, Upper Arms, Lower Arms' -- Robes
|
|
WHEN 30 THEN 'Shirt' -- Full shirts
|
|
WHEN 14336 THEN 'Chest, Abdomen, Upper Arms, Lower Arms' -- Hauberks
|
|
WHEN 25600 THEN 'Abdomen, Upper Legs, Lower Legs' -- Tassets
|
|
ELSE
|
|
-- For other combinations, decode bit flags
|
|
CONCAT_WS(', ',
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1 = 1 THEN 'Head' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 512 = 512 THEN 'Chest' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1024 = 1024 THEN 'Abdomen' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 2048 = 2048 THEN 'Upper Arms' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 4096 = 4096 THEN 'Lower Arms' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 32 = 32 THEN 'Hands' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 8192 = 8192 THEN 'Upper Legs' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 16384 = 16384 THEN 'Lower Legs' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 256 = 256 THEN 'Feet' END
|
|
)
|
|
END
|
|
|
|
-- JEWELRY: Check wielded location first, then name patterns
|
|
WHEN i.object_class = 4 THEN
|
|
CASE
|
|
-- Use wielded location if equipped
|
|
WHEN i.current_wielded_location = 32768 THEN 'Neck'
|
|
WHEN i.current_wielded_location = 262144 THEN 'Left Ring'
|
|
WHEN i.current_wielded_location = 524288 THEN 'Right Ring'
|
|
WHEN i.current_wielded_location = 786432 THEN 'Left Ring, Right Ring'
|
|
WHEN i.current_wielded_location = 131072 THEN 'Left Wrist'
|
|
WHEN i.current_wielded_location = 1048576 THEN 'Right Wrist'
|
|
WHEN i.current_wielded_location = 1179648 THEN 'Left Wrist, Right Wrist'
|
|
-- Fallback to name patterns for unequipped
|
|
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
|
|
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Left Ring, Right Ring'
|
|
WHEN i.name ILIKE '%bracelet%' THEN 'Left Wrist, Right Wrist'
|
|
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
|
|
ELSE 'Jewelry'
|
|
END
|
|
|
|
-- WEAPONS: Use object class
|
|
WHEN i.object_class = 6 THEN 'Melee Weapon'
|
|
WHEN i.object_class = 7 THEN 'Missile Weapon'
|
|
WHEN i.object_class = 8 THEN 'Held'
|
|
|
|
-- Check wielded location for two-handed weapons
|
|
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
|
|
|
|
-- CLOAKS: Identify by name pattern
|
|
WHEN i.name ILIKE '%cloak%' THEN 'Cloak'
|
|
|
|
-- DEFAULT
|
|
ELSE '-'
|
|
END as computed_slot_name,
|
|
|
|
-- Compute spell_names (spell IDs for client-side name lookup)
|
|
COALESCE(
|
|
(SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
|
|
FROM item_spells sp_inner WHERE sp_inner.item_id = i.id),
|
|
''
|
|
) as computed_spell_names
|
|
|
|
FROM items i
|
|
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
|
|
LEFT JOIN item_ratings rt ON i.id = rt.item_id
|
|
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
|
|
cte_where_clause = None
|
|
if underwear_filter_type == "shirts":
|
|
# Shirts: ObjectClass 3 with UnderwearChest (8) but NOT pants patterns
|
|
# Exclude items that have both UnderwearUpperLegs (2) and UnderwearLowerLegs (4) which indicate pants
|
|
cte_where_clause = """WHERE i.object_class = 3
|
|
AND ((rd.int_values->>'218103821')::int & 8) > 0
|
|
AND NOT ((rd.int_values->>'218103821')::int & 6) = 6
|
|
AND i.name NOT ILIKE '%robe%'
|
|
AND i.name NOT ILIKE '%cloak%'
|
|
AND i.name NOT ILIKE '%pallium%'
|
|
AND i.name NOT ILIKE '%armet%'
|
|
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
|
|
# 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
|
|
AND i.name NOT ILIKE '%robe%'
|
|
AND i.name NOT ILIKE '%cloak%'
|
|
AND i.name NOT ILIKE '%pallium%'
|
|
AND i.name NOT ILIKE '%armet%'"""
|
|
elif underwear_filter_type == "all_underwear":
|
|
# All underwear: ObjectClass 3 with any underwear bits (2,4,8,16)
|
|
cte_where_clause = """WHERE i.object_class = 3
|
|
AND ((rd.int_values->>'218103821')::int & 30) > 0
|
|
AND i.name NOT ILIKE '%robe%'
|
|
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 )",
|
|
)
|
|
|
|
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()
|
|
]
|
|
if character_list:
|
|
# Create parameterized IN clause
|
|
char_params = []
|
|
for i, char_name in enumerate(character_list):
|
|
param_name = f"char_{i}"
|
|
char_params.append(f":{param_name}")
|
|
params[param_name] = char_name
|
|
conditions.append(f"character_name IN ({', '.join(char_params)})")
|
|
else:
|
|
return {
|
|
"error": "Empty characters list provided",
|
|
"items": [],
|
|
"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,
|
|
}
|
|
|
|
# Text search (name with material support)
|
|
if text:
|
|
# Search both the concatenated material+name and base name
|
|
# This handles searches like "Silver Celdon Girth" or just "silver" for material
|
|
conditions.append("""(
|
|
CONCAT(COALESCE(material, ''), ' ', name) ILIKE :text OR
|
|
name ILIKE :text OR
|
|
COALESCE(material, '') ILIKE :text
|
|
)""")
|
|
params["text"] = f"%{text}%"
|
|
|
|
# Item category filtering
|
|
if armor_only:
|
|
# Armor: ObjectClass 2 (Armor) with armor_level > 0
|
|
conditions.append("(object_class = 2 AND COALESCE(armor_level, 0) > 0)")
|
|
elif jewelry_only:
|
|
# Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets
|
|
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)"
|
|
)
|
|
elif clothing_only:
|
|
# Clothing: ObjectClass 3 (Clothing) - shirts and pants only, exclude cloaks and robes
|
|
# Focus on underclothes: shirts, pants, breeches, etc.
|
|
conditions.append("""(object_class = 3 AND
|
|
name NOT ILIKE '%cloak%' AND
|
|
name NOT ILIKE '%robe%' AND
|
|
name NOT ILIKE '%pallium%' AND
|
|
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""",
|
|
)
|
|
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", {})
|
|
for sid, spell_data in spells.items():
|
|
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", {})
|
|
for sid, spell_data in spells.items():
|
|
if isinstance(spell_data, dict):
|
|
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))})"
|
|
)
|
|
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(",")]
|
|
matching_spell_ids = []
|
|
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()
|
|
# Match cantrip name (flexible matching)
|
|
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}"
|
|
)
|
|
|
|
if not found_match:
|
|
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
|
|
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))})
|
|
) >= {num_required_spells}""")
|
|
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"
|
|
)
|
|
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()]
|
|
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":
|
|
slot_approaches.append("(computed_slot_name ILIKE '%Ring%')")
|
|
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})"
|
|
)
|
|
params[param_name] = f"%{slot_name}%"
|
|
|
|
# Approach 2: Specific jewelry logic for common cases
|
|
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":
|
|
# 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"]:
|
|
# For bracelets: object_class = 4 with bracelet in name
|
|
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%'))"
|
|
)
|
|
# 3. Gems with trinket names
|
|
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":
|
|
# For cloaks: identify by name pattern
|
|
slot_approaches.append("(name ILIKE '%cloak%')")
|
|
slot_approaches.append("(computed_slot_name = 'Cloak')")
|
|
|
|
# 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}"
|
|
)
|
|
|
|
# Combat properties
|
|
if min_damage is not None:
|
|
conditions.append("max_damage >= :min_damage")
|
|
params["min_damage"] = min_damage
|
|
if max_damage is not None:
|
|
conditions.append("max_damage <= :max_damage")
|
|
params["max_damage"] = max_damage
|
|
if min_armor is not None:
|
|
conditions.append("armor_level >= :min_armor")
|
|
params["min_armor"] = min_armor
|
|
if max_armor is not None:
|
|
conditions.append("armor_level <= :max_armor")
|
|
params["max_armor"] = max_armor
|
|
if min_attack_bonus is not None:
|
|
conditions.append("attack_bonus >= :min_attack_bonus")
|
|
params["min_attack_bonus"] = min_attack_bonus
|
|
if min_crit_damage_rating is not None:
|
|
conditions.append("crit_damage_rating >= :min_crit_damage_rating")
|
|
params["min_crit_damage_rating"] = min_crit_damage_rating
|
|
if min_damage_rating is not None:
|
|
conditions.append("damage_rating >= :min_damage_rating")
|
|
params["min_damage_rating"] = min_damage_rating
|
|
if min_heal_boost_rating is not None:
|
|
conditions.append("heal_boost_rating >= :min_heal_boost_rating")
|
|
params["min_heal_boost_rating"] = min_heal_boost_rating
|
|
if min_vitality_rating is not None:
|
|
conditions.append("vitality_rating >= :min_vitality_rating")
|
|
params["min_vitality_rating"] = min_vitality_rating
|
|
if min_damage_resist_rating is not None:
|
|
conditions.append("damage_resist_rating >= :min_damage_resist_rating")
|
|
params["min_damage_resist_rating"] = min_damage_resist_rating
|
|
if min_crit_resist_rating is not None:
|
|
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"
|
|
)
|
|
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")
|
|
params["min_healing_resist_rating"] = min_healing_resist_rating
|
|
if min_nether_resist_rating is not None:
|
|
conditions.append("nether_resist_rating >= :min_nether_resist_rating")
|
|
params["min_nether_resist_rating"] = min_nether_resist_rating
|
|
if min_healing_rating is not None:
|
|
conditions.append("healing_rating >= :min_healing_rating")
|
|
params["min_healing_rating"] = min_healing_rating
|
|
if min_dot_resist_rating is not None:
|
|
conditions.append("dot_resist_rating >= :min_dot_resist_rating")
|
|
params["min_dot_resist_rating"] = min_dot_resist_rating
|
|
if min_life_resist_rating is not None:
|
|
conditions.append("life_resist_rating >= :min_life_resist_rating")
|
|
params["min_life_resist_rating"] = min_life_resist_rating
|
|
if min_sneak_attack_rating is not None:
|
|
conditions.append("sneak_attack_rating >= :min_sneak_attack_rating")
|
|
params["min_sneak_attack_rating"] = min_sneak_attack_rating
|
|
if min_recklessness_rating is not None:
|
|
conditions.append("recklessness_rating >= :min_recklessness_rating")
|
|
params["min_recklessness_rating"] = min_recklessness_rating
|
|
if min_deception_rating is not None:
|
|
conditions.append("deception_rating >= :min_deception_rating")
|
|
params["min_deception_rating"] = min_deception_rating
|
|
if min_pk_damage_rating is not None:
|
|
conditions.append("pk_damage_rating >= :min_pk_damage_rating")
|
|
params["min_pk_damage_rating"] = min_pk_damage_rating
|
|
if min_pk_damage_resist_rating is not None:
|
|
conditions.append("pk_damage_resist_rating >= :min_pk_damage_resist_rating")
|
|
params["min_pk_damage_resist_rating"] = min_pk_damage_resist_rating
|
|
if min_gear_pk_damage_rating is not None:
|
|
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
|
|
)
|
|
|
|
# Requirements
|
|
if max_level is not None:
|
|
conditions.append("(wield_level <= :max_level OR wield_level IS NULL)")
|
|
params["max_level"] = max_level
|
|
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")
|
|
params["material"] = f"%{material}%"
|
|
if min_workmanship is not None:
|
|
conditions.append("workmanship >= :min_workmanship")
|
|
params["min_workmanship"] = min_workmanship
|
|
if has_imbue is not None:
|
|
if has_imbue:
|
|
conditions.append("imbue IS NOT NULL AND imbue != ''")
|
|
else:
|
|
conditions.append("(imbue IS NULL OR imbue = '')")
|
|
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}'"
|
|
)
|
|
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()
|
|
]
|
|
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"
|
|
)
|
|
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}'"
|
|
)
|
|
conditions.append("item_set = :item_set")
|
|
params["item_set"] = set_name
|
|
else:
|
|
# Empty sets list - no results
|
|
conditions.append("1 = 0")
|
|
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")
|
|
if attuned is not None:
|
|
conditions.append("attuned > 0" if attuned else "attuned = 0")
|
|
if unique is not None:
|
|
conditions.append("unique = :unique")
|
|
params["unique"] = unique
|
|
if is_rare is not None:
|
|
if is_rare:
|
|
conditions.append("rare_id IS NOT NULL AND rare_id > 0")
|
|
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)"
|
|
)
|
|
params["min_condition"] = min_condition
|
|
|
|
# Value/utility
|
|
if min_value is not None:
|
|
conditions.append("value >= :min_value")
|
|
params["min_value"] = min_value
|
|
if max_value is not None:
|
|
conditions.append("value <= :max_value")
|
|
params["max_value"] = max_value
|
|
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",
|
|
"character_name": "character_name",
|
|
"value": "value",
|
|
"damage": "max_damage",
|
|
"armor": "armor_level",
|
|
"armor_level": "armor_level",
|
|
"workmanship": "workmanship",
|
|
"level": "wield_level",
|
|
"damage_rating": "damage_rating",
|
|
"crit_damage_rating": "crit_damage_rating",
|
|
"heal_boost_rating": "heal_boost_rating",
|
|
"vitality_rating": "vitality_rating",
|
|
"damage_resist_rating": "damage_resist_rating",
|
|
"crit_damage_resist_rating": "crit_damage_resist_rating",
|
|
"item_set": "item_set",
|
|
"slot_name": "computed_slot_name",
|
|
"coverage": "coverage_mask",
|
|
"item_type_name": "object_class",
|
|
"last_updated": "timestamp",
|
|
"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"
|
|
)
|
|
|
|
# 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 = [
|
|
"""
|
|
WITH items_with_slots AS (
|
|
SELECT DISTINCT
|
|
i.id as db_item_id,
|
|
i.character_name,
|
|
i.name,
|
|
i.object_class,
|
|
i.value,
|
|
i.burden,
|
|
i.current_wielded_location,
|
|
i.bonded,
|
|
i.attuned,
|
|
i.unique,
|
|
i.structure,
|
|
i.max_structure,
|
|
i.rare_id,
|
|
COALESCE(cs.max_damage, -1) as max_damage,
|
|
COALESCE(cs.armor_level, -1) as armor_level,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'314')::int, -1),
|
|
COALESCE((rd.int_values->>'374')::int, -1)
|
|
) as crit_damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'307')::int, -1),
|
|
COALESCE((rd.int_values->>'370')::int, -1)
|
|
) as damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'323')::int, -1),
|
|
COALESCE((rd.int_values->>'376')::int, -1)
|
|
) as heal_boost_rating,
|
|
COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'308')::int, -1),
|
|
COALESCE((rd.int_values->>'371')::int, -1)
|
|
) as damage_resist_rating,
|
|
COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'316')::int, -1),
|
|
COALESCE((rd.int_values->>'375')::int, -1)
|
|
) as crit_damage_resist_rating,
|
|
COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating,
|
|
COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating,
|
|
COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating,
|
|
COALESCE((rd.int_values->>'351')::int, -1) as life_resist_rating,
|
|
COALESCE(req.wield_level, -1) as wield_level,
|
|
COALESCE(enh.material, '') as material,
|
|
COALESCE(enh.workmanship, -1.0) as workmanship,
|
|
COALESCE(enh.imbue, '') as imbue,
|
|
COALESCE(enh.tinks, -1) as tinks,
|
|
COALESCE(enh.item_set, '') as item_set,
|
|
|
|
-- Same computed slot logic for count query
|
|
CASE
|
|
WHEN rd.original_json IS NOT NULL
|
|
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
|
|
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
|
|
THEN
|
|
CASE (rd.original_json->'IntValues'->>'218103822')::int
|
|
WHEN 1 THEN 'Head'
|
|
WHEN 2 THEN 'Neck'
|
|
WHEN 4 THEN 'Shirt'
|
|
WHEN 16 THEN 'Chest'
|
|
WHEN 32 THEN 'Hands'
|
|
WHEN 256 THEN 'Feet'
|
|
WHEN 512 THEN 'Chest'
|
|
WHEN 1024 THEN 'Abdomen'
|
|
WHEN 2048 THEN 'Upper Arms'
|
|
WHEN 4096 THEN 'Lower Arms'
|
|
WHEN 8192 THEN 'Upper Legs'
|
|
WHEN 16384 THEN 'Lower Legs'
|
|
WHEN 33554432 THEN 'Shield'
|
|
ELSE 'Armor'
|
|
END
|
|
WHEN i.object_class = 4 THEN
|
|
CASE
|
|
WHEN i.current_wielded_location = 32768 THEN 'Neck'
|
|
WHEN i.current_wielded_location IN (262144, 524288, 786432) THEN 'Ring'
|
|
WHEN i.current_wielded_location IN (131072, 1048576, 1179648) THEN 'Bracelet'
|
|
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
|
|
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Ring'
|
|
WHEN i.name ILIKE '%bracelet%' THEN 'Bracelet'
|
|
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
|
|
ELSE 'Jewelry'
|
|
END
|
|
WHEN i.object_class = 6 THEN 'Melee Weapon'
|
|
WHEN i.object_class = 7 THEN 'Missile Weapon'
|
|
WHEN i.object_class = 8 THEN 'Held'
|
|
ELSE '-'
|
|
END as computed_slot_name
|
|
|
|
FROM items i
|
|
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
|
|
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
|
|
|
|
# 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
|
|
)
|
|
else:
|
|
item["condition_percent"] = None
|
|
|
|
# Apply comprehensive translations from original_json (like individual inventory endpoint)
|
|
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"
|
|
):
|
|
material_name = None
|
|
if item.get("material"):
|
|
# Check if material is already a string or needs translation
|
|
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
|
|
# 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
|
|
|
|
# Add object class translation
|
|
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"):
|
|
# 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", "")}
|
|
)
|
|
|
|
# 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"]
|
|
|
|
# Add coverage calculation from coverage mask
|
|
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"]
|
|
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)
|
|
else:
|
|
item["coverage"] = f"Coverage_{coverage_value}"
|
|
# Add raw coverage mask for armor reduction system
|
|
item["coverage_mask"] = coverage_value
|
|
else:
|
|
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)
|
|
)
|
|
|
|
# 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 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") != ""
|
|
)
|
|
|
|
# Get sophisticated slot options using Mag-SuitBuilder logic
|
|
slot_options = get_sophisticated_slot_options(
|
|
int(equippable_slots),
|
|
int(coverage_value) if coverage_value else 0,
|
|
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 "-"
|
|
)
|
|
else:
|
|
item["slot_name"] = "-"
|
|
else:
|
|
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)
|
|
if gear_damage > 0:
|
|
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
|
|
)
|
|
if gear_crit_damage > 0:
|
|
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
|
|
)
|
|
if gear_healing_boost > 0:
|
|
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
|
|
|
|
# Add equipment set name translation
|
|
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", {})
|
|
if set_id in attribute_set_info:
|
|
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", {})
|
|
if set_id in equipment_sets:
|
|
item["item_set_name"] = equipment_sets[set_id]
|
|
else:
|
|
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)
|
|
|
|
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,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Search error: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
|
|
|
|
|
|
@app.get("/search/equipped/{character_name}")
|
|
async def search_equipped_items(
|
|
character_name: str,
|
|
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",
|
|
]
|
|
params = {"character_name": character_name}
|
|
|
|
if slot is not None:
|
|
conditions.append("i.current_wielded_location = :slot")
|
|
params["slot"] = slot
|
|
|
|
query = (
|
|
"""
|
|
SELECT
|
|
i.*,
|
|
COALESCE(cs.max_damage, -1) as max_damage,
|
|
COALESCE(cs.armor_level, -1) as armor_level,
|
|
COALESCE(cs.attack_bonus, -1.0) as attack_bonus,
|
|
COALESCE(req.wield_level, -1) as wield_level,
|
|
COALESCE(enh.material, '') as material,
|
|
COALESCE(enh.workmanship, -1.0) as workmanship,
|
|
COALESCE(enh.item_set, '') as item_set
|
|
FROM items i
|
|
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)
|
|
+ """
|
|
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"]
|
|
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']}",
|
|
)
|
|
items.append(item)
|
|
|
|
return {
|
|
"character_name": character_name,
|
|
"equipped_items": items,
|
|
"slot_filter": slot,
|
|
"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)}"
|
|
)
|
|
|
|
|
|
@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"
|
|
),
|
|
):
|
|
"""Find potential equipment upgrades for a specific slot."""
|
|
try:
|
|
# Get currently equipped item in this slot
|
|
current_query = """
|
|
SELECT i.*, cs.max_damage, cs.armor_level, enh.workmanship
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
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}
|
|
)
|
|
|
|
# Find all unequipped items that could be equipped in this slot
|
|
# Check ValidLocations or infer from similar equipped items
|
|
upgrade_query = """
|
|
SELECT DISTINCT
|
|
i.character_name,
|
|
i.name,
|
|
i.value,
|
|
i.burden,
|
|
COALESCE(cs.max_damage, -1) as max_damage,
|
|
COALESCE(cs.armor_level, -1) as armor_level,
|
|
COALESCE(enh.workmanship, -1.0) as workmanship,
|
|
COALESCE(req.wield_level, -1) as wield_level
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
LEFT JOIN item_requirements req ON i.id = req.item_id
|
|
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"
|
|
params["current_damage"] = current_item["max_damage"]
|
|
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
|
|
):
|
|
upgrade_query += " AND enh.workmanship > :current_workmanship"
|
|
params["current_workmanship"] = current_item["workmanship"]
|
|
elif upgrade_type == "value":
|
|
upgrade_query += " AND i.value > :current_value"
|
|
params["current_value"] = current_item["value"]
|
|
else:
|
|
# 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"
|
|
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),
|
|
}
|
|
|
|
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)}"
|
|
)
|
|
|
|
|
|
@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:
|
|
query = """
|
|
SELECT character_name, COUNT(*) as item_count, MAX(timestamp) as last_updated
|
|
FROM items
|
|
GROUP BY character_name
|
|
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)}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list inventory characters: {e}")
|
|
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"
|
|
),
|
|
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),
|
|
):
|
|
"""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",
|
|
}
|
|
|
|
# Character filtering
|
|
if not include_all_characters and characters:
|
|
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,
|
|
CASE WHEN i.current_wielded_location > 0 THEN true ELSE false END as is_equipped
|
|
FROM items i
|
|
WHERE {where_clause}
|
|
ORDER BY i.character_name, i.name
|
|
LIMIT :limit 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_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,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in slot search: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/analyze/sets")
|
|
async def analyze_set_combinations(
|
|
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"
|
|
),
|
|
):
|
|
"""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))
|
|
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",
|
|
)
|
|
|
|
# Query for primary set
|
|
primary_query = f"""
|
|
SELECT
|
|
i.character_name,
|
|
i.name,
|
|
i.current_wielded_location
|
|
FROM items i
|
|
LEFT JOIN item_raw_data ird ON i.id = ird.item_id
|
|
WHERE {char_filter}
|
|
AND i.object_class IN (2, 3)
|
|
AND ird.int_values ? '265'
|
|
AND (ird.int_values->>'265')::int = :primary_set_id
|
|
"""
|
|
|
|
# Query for secondary set
|
|
secondary_query = f"""
|
|
SELECT
|
|
i.character_name,
|
|
i.name,
|
|
i.current_wielded_location
|
|
FROM items i
|
|
LEFT JOIN item_raw_data ird ON i.id = ird.item_id
|
|
WHERE {char_filter}
|
|
AND i.object_class IN (2, 3)
|
|
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}
|
|
secondary_params = {"secondary_set_id": secondary_set}
|
|
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()]
|
|
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_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"]
|
|
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}
|
|
)
|
|
|
|
for row in secondary_result:
|
|
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}
|
|
)
|
|
|
|
# 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,
|
|
}
|
|
)
|
|
|
|
# Sort by characters who can build first
|
|
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}"
|
|
)
|
|
|
|
return {
|
|
"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,
|
|
},
|
|
"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))
|
|
|
|
|
|
@app.get("/slots/available")
|
|
async def get_available_items_by_slot(
|
|
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"
|
|
),
|
|
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"),
|
|
):
|
|
"""Get available items organized by equipment slot across multiple characters."""
|
|
try:
|
|
# Build character filter
|
|
if include_all_characters:
|
|
char_filter = "1=1"
|
|
query_params = {}
|
|
elif characters:
|
|
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",
|
|
)
|
|
|
|
# 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
|
|
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
|
|
|
|
# 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]
|
|
)
|
|
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}"
|
|
)
|
|
if 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}"
|
|
)
|
|
|
|
# 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
|
|
i.id,
|
|
i.character_name,
|
|
i.name,
|
|
i.object_class,
|
|
i.current_wielded_location,
|
|
CASE
|
|
WHEN ird.int_values ? '9' THEN (ird.int_values->>'9')::int
|
|
ELSE 0
|
|
END as valid_locations,
|
|
CASE
|
|
WHEN ird.int_values ? '218103822' THEN (ird.int_values->>'218103822')::int
|
|
WHEN ird.int_values ? '218103821' THEN (ird.int_values->>'218103821')::int
|
|
ELSE 0
|
|
END as coverage_mask,
|
|
CASE
|
|
WHEN ird.int_values ? '265' THEN (ird.int_values->>'265')::int
|
|
ELSE NULL
|
|
END as item_set_id,
|
|
CASE
|
|
WHEN ird.int_values ? '28' THEN (ird.int_values->>'28')::int
|
|
ELSE 0
|
|
END as armor_level,
|
|
CASE
|
|
WHEN ird.int_values ? '370' THEN (ird.int_values->>'370')::int
|
|
ELSE 0
|
|
END as crit_damage_rating,
|
|
CASE
|
|
WHEN ird.int_values ? '372' THEN (ird.int_values->>'372')::int
|
|
ELSE 0
|
|
END as damage_rating,
|
|
CASE
|
|
WHEN ird.int_values ? '16' THEN (ird.int_values->>'16')::int
|
|
ELSE NULL
|
|
END as material_type
|
|
FROM items i
|
|
LEFT JOIN item_raw_data ird ON i.id = ird.item_id
|
|
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()
|
|
]
|
|
# Get spell IDs for the requested cantrips
|
|
cantrip_spell_ids = []
|
|
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"""
|
|
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",
|
|
2048: "Upper Arms",
|
|
4096: "Lower Arms",
|
|
32: "Hands",
|
|
1024: "Abdomen",
|
|
8192: "Upper Legs",
|
|
16384: "Lower Legs",
|
|
256: "Feet",
|
|
}
|
|
|
|
# Jewelry slots
|
|
jewelry_slots = {
|
|
"Neck": "Neck",
|
|
"Left Ring": "Left Ring",
|
|
"Right Ring": "Right Ring",
|
|
"Left Wrist": "Left 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"],
|
|
}
|
|
|
|
# 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']}"
|
|
|
|
# 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 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"]
|
|
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
|
|
)
|
|
|
|
# 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:
|
|
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"]):
|
|
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"]
|
|
):
|
|
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"]))
|
|
|
|
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,
|
|
"min_crit_damage_rating": min_crit_damage_rating,
|
|
"min_damage_rating": min_damage_rating,
|
|
"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))
|
|
|
|
|
|
@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"
|
|
),
|
|
# 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)"
|
|
),
|
|
# 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"
|
|
),
|
|
# Rating requirements
|
|
min_armor: int = Query(None, description="Minimum total armor level"),
|
|
max_armor: int = Query(None, description="Maximum total armor level"),
|
|
min_crit_damage: int = Query(None, description="Minimum total crit damage rating"),
|
|
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"
|
|
),
|
|
# Result options
|
|
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:
|
|
# Parse character selection
|
|
char_list = []
|
|
if include_all_characters:
|
|
# Get all unique character names
|
|
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]
|
|
elif characters:
|
|
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}
|
|
|
|
# Determine equipment status filtering logic
|
|
equipment_status_filter = None
|
|
if include_equipped and include_inventory:
|
|
equipment_status_filter = "both" # Mix equipped and inventory items
|
|
elif include_equipped:
|
|
equipment_status_filter = "equipped_only" # Only currently equipped items
|
|
elif include_inventory:
|
|
equipment_status_filter = "inventory_only" # Only unequipped items
|
|
else:
|
|
return {
|
|
"suits": [],
|
|
"message": "Must include either equipped or inventory items",
|
|
"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,
|
|
}
|
|
|
|
# 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"],
|
|
"armor_items_available": results.get("armor_items_available", 0),
|
|
"accessory_items_available": results.get("accessory_items_available", 0),
|
|
"message": results.get("message", "Search completed successfully"),
|
|
"constraints": constraints,
|
|
"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"),
|
|
):
|
|
"""Debug endpoint to see what equipment sets are available"""
|
|
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
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
WHERE i.character_name = ANY(:characters)
|
|
AND cs.armor_level > 0
|
|
AND enh.item_set IS NOT NULL
|
|
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
|
|
]
|
|
}
|
|
|
|
|
|
@app.get("/debug/test-simple-search")
|
|
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()]
|
|
|
|
# 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",
|
|
}
|
|
|
|
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
|
|
}
|
|
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"
|
|
),
|
|
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"
|
|
),
|
|
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"
|
|
),
|
|
):
|
|
"""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()]
|
|
|
|
# Determine equipment status filter
|
|
if include_equipped and include_inventory:
|
|
equipment_status_filter = "both"
|
|
elif include_equipped:
|
|
equipment_status_filter = "equipped_only"
|
|
elif include_inventory:
|
|
equipment_status_filter = "inventory_only"
|
|
else:
|
|
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,
|
|
}
|
|
|
|
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},
|
|
}
|
|
|
|
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}")
|
|
armor_items = await solver._get_armor_items_with_set_filtering()
|
|
logger.info(f"Found {len(armor_items)} armor items")
|
|
if not armor_items:
|
|
logger.warning("No armor items found matching set requirements")
|
|
yield {
|
|
"event": "error",
|
|
"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,
|
|
}
|
|
),
|
|
}
|
|
|
|
# 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"}),
|
|
}
|
|
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)"
|
|
}
|
|
),
|
|
}
|
|
break
|
|
|
|
# Complete suit with accessories
|
|
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']}"
|
|
)
|
|
if scored_suits[0]["score"] > 0:
|
|
found_count += 1
|
|
|
|
# Yield the suit
|
|
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
|
|
),
|
|
}
|
|
),
|
|
}
|
|
else:
|
|
logger.info(f"Combination {i}: suit scored 0, skipping")
|
|
else:
|
|
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"}),
|
|
}
|
|
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),
|
|
}
|
|
),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in streaming search: {e}")
|
|
yield {"event": "error", "data": json.dumps({"message": str(e)})}
|
|
|
|
return EventSourceResponse(generate_suits())
|
|
|
|
|
|
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()
|
|
|
|
# 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",
|
|
]
|
|
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 = {
|
|
"item_id": row["item_id"],
|
|
"character_name": row["character_name"],
|
|
"name": row["name"],
|
|
"icon": row["icon"],
|
|
"object_class": row["object_class"],
|
|
"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"],
|
|
"spell_names": row["spell_names"] or [],
|
|
"valid_locations": row["valid_locations"],
|
|
"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"],
|
|
"possible_slots": possible_slots,
|
|
"is_equipped": item["is_equipped"],
|
|
"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
|
|
|
|
|
|
def decode_valid_locations_to_jewelry_slots(valid_locations):
|
|
"""
|
|
Decode ValidLocations bitmask to jewelry slot names.
|
|
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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
for bit_value, slot_name in jewelry_bits.items():
|
|
if valid_locations & bit_value:
|
|
slots.append(slot_name)
|
|
|
|
return slots
|
|
|
|
|
|
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"]):
|
|
slots.extend(["Left Wrist", "Right Wrist"])
|
|
elif any(word in item_name for word in ["necklace", "amulet", "gorget"]):
|
|
slots.append("Neck")
|
|
elif any(word in item_name for word in ["trinket", "compass", "goggles"]):
|
|
slots.append("Trinket")
|
|
else:
|
|
# Default jewelry fallback
|
|
slots.append("Trinket")
|
|
|
|
return slots
|
|
|
|
|
|
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)
|
|
if jewelry_slots:
|
|
slots.extend(jewelry_slots)
|
|
else:
|
|
# Fallback to name-based detection
|
|
slots.extend(detect_jewelry_slots_by_name(item_name))
|
|
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")
|
|
return slots
|
|
|
|
# Use coverage mask if available
|
|
if coverage and coverage > 0:
|
|
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"]
|
|
):
|
|
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)
|
|
# Use coverage mask detection for all ObjectClass 3 items
|
|
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"]):
|
|
slots.append("Feet")
|
|
elif any(word in item_name for word in ["pant", "trouser", "legging"]):
|
|
# Armor leggings (ObjectClass 2)
|
|
slots.extend(["Abdomen", "Upper Legs", "Lower Legs"])
|
|
elif "bracer" in item_name:
|
|
slots.append("Lower Arms")
|
|
elif "pauldron" in item_name:
|
|
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=""):
|
|
"""
|
|
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
|
|
# Shirts: UnderwearChest (8) OR UnderwearAbdomen (16) = mask & 24 > 0
|
|
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)
|
|
)
|
|
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)
|
|
)
|
|
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
|
|
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
|
|
slots.append("Pants")
|
|
return slots
|
|
# Also check for simple pants pattern without abdomen
|
|
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"]
|
|
):
|
|
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
|
|
}
|
|
|
|
# Jewelry coverage bit mappings
|
|
jewelry_coverage_map = {
|
|
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
|
|
}
|
|
|
|
# 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
|
|
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
|
|
|
|
|
|
def categorize_items_by_set(items):
|
|
"""Categorize items by equipment set for efficient set-based optimization."""
|
|
items_by_set = {}
|
|
for item in items:
|
|
set_id = item.get("item_set_id")
|
|
if set_id:
|
|
if set_id not in items_by_set:
|
|
items_by_set[set_id] = []
|
|
items_by_set[set_id].append(item)
|
|
return items_by_set
|
|
|
|
|
|
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,
|
|
):
|
|
"""Build suit prioritizing equipment set requirements first."""
|
|
suit = {
|
|
"items": {},
|
|
"stats": {
|
|
"primary_set_count": 0,
|
|
"secondary_set_count": 0,
|
|
"required_spells_found": 0,
|
|
},
|
|
"missing": [],
|
|
"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
|
|
)
|
|
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
|
|
)
|
|
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,
|
|
):
|
|
"""Build suit prioritizing spell requirements first."""
|
|
suit = {
|
|
"items": {},
|
|
"stats": {
|
|
"primary_set_count": 0,
|
|
"secondary_set_count": 0,
|
|
"required_spells_found": 0,
|
|
},
|
|
"missing": [],
|
|
"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,
|
|
)
|
|
for item in spell_items[:2]: # Limit to prevent spell hogging
|
|
if item["item_id"] not in used_items:
|
|
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"])
|
|
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
|
|
)
|
|
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
|
|
)
|
|
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,
|
|
):
|
|
"""Build suit using balanced approach between sets and spells."""
|
|
suit = {
|
|
"items": {},
|
|
"stats": {
|
|
"primary_set_count": 0,
|
|
"secondary_set_count": 0,
|
|
"required_spells_found": 0,
|
|
},
|
|
"missing": [],
|
|
"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",
|
|
]
|
|
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
|
|
)
|
|
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
|
|
):
|
|
"""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"]:
|
|
suit["items"][slot] = item
|
|
used_items.add(item["item_id"])
|
|
placed_count += 1
|
|
placed = True
|
|
break
|
|
# If replace_ok and this item is better, replace existing
|
|
elif replace_ok and item_value(item) > item_value(suit["items"][slot]):
|
|
old_item = suit["items"][slot]
|
|
used_items.discard(old_item["item_id"])
|
|
suit["items"][slot] = item
|
|
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",
|
|
]
|
|
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
|
|
]
|
|
|
|
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
|
|
)
|
|
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"])
|
|
|
|
|
|
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()
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
def calculate_suit_stats(suit, primary_set, secondary_set, required_spells):
|
|
"""Calculate comprehensive statistics for a suit."""
|
|
suit["stats"] = {
|
|
"total_armor": 0,
|
|
"total_crit_damage": 0,
|
|
"total_damage_rating": 0,
|
|
"primary_set_count": 0,
|
|
"secondary_set_count": 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_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"
|
|
)
|
|
|
|
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,
|
|
}
|
|
|
|
# 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
|
|
)
|
|
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),
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error in constraint solver: {e}")
|
|
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
|
|
item_spells = item.get("spell_names", "")
|
|
if isinstance(item_spells, str):
|
|
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
|
|
)
|
|
|
|
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:
|
|
for required_spell in required_spell_names:
|
|
for item_spell in item_spell_list:
|
|
# 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
|
|
)
|
|
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
|
|
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):
|
|
"""Phase 1: ArmorSearcher - Get armor with strict constraint filtering"""
|
|
query = """
|
|
SELECT DISTINCT i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class,
|
|
i.current_wielded_location, i.timestamp,
|
|
enh.item_set as item_set_id,
|
|
cs.armor_level,
|
|
rd.int_values->>'374' as gear_crit_damage,
|
|
rd.int_values->>'370' as gear_damage_rating,
|
|
rd.int_values->>'372' as gear_heal_boost,
|
|
COALESCE((rd.int_values->>'218103821')::int, 0) as coverage,
|
|
string_agg(isp.spell_id::text, ',') as spell_ids
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
|
|
LEFT JOIN item_spells isp ON i.id = isp.item_id
|
|
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}"
|
|
)
|
|
|
|
# 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", {})
|
|
|
|
for row in rows:
|
|
item = dict(row)
|
|
# Apply proper slot detection (including clothing)
|
|
item_for_slots = {
|
|
"object_class": item.get("object_class"),
|
|
"coverage_mask": item.get("coverage", 0),
|
|
"name": item.get("name", ""),
|
|
"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()
|
|
]
|
|
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_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]}...)"
|
|
)
|
|
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"
|
|
)
|
|
return items
|
|
|
|
async def _get_accessory_items(self):
|
|
"""Phase 2: AccessorySearcher - Get jewelry/clothing with constraint filtering"""
|
|
query = """
|
|
SELECT DISTINCT i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class,
|
|
i.current_wielded_location, i.timestamp,
|
|
enh.item_set as item_set_id,
|
|
cs.armor_level,
|
|
COALESCE((rd.int_values->>'218103821')::int, 0) as coverage,
|
|
COALESCE((rd.int_values->>'9')::int, 0) as valid_locations,
|
|
COALESCE((rd.int_values->>'374')::int, 0) as gear_crit_damage,
|
|
COALESCE((rd.int_values->>'370')::int, 0) as gear_damage_rating,
|
|
COALESCE((rd.int_values->>'372')::int, 0) as gear_heal_boost,
|
|
string_agg(isp.spell_id::text, ',') as spell_ids
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
|
|
LEFT JOIN item_spells isp ON i.id = isp.item_id
|
|
WHERE i.character_name = ANY(:characters)
|
|
AND (i.object_class = 4 -- Jewelry (ObjectClass 4)
|
|
OR (i.object_class = 3 AND (
|
|
((rd.int_values->>'218103821')::int & 1024 = 1024 AND (rd.int_values->>'218103821')::int & 4096 = 4096 AND (rd.int_values->>'218103821')::int & 8192 = 8192) -- Shirt pattern (13312)
|
|
OR ((rd.int_values->>'218103821')::int & 256 = 256 AND (rd.int_values->>'218103821')::int & 512 = 512 AND (rd.int_values->>'218103821')::int & 2048 = 2048) -- Pants pattern (2816)
|
|
OR (rd.int_values->>'218103821')::int & 24 = 24 -- Underwear Shirt
|
|
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,
|
|
enh.item_set, cs.armor_level, rd.int_values->>'218103821',
|
|
rd.int_values->>'9', rd.int_values->>'374',
|
|
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", {})
|
|
|
|
for row in rows:
|
|
item = dict(row)
|
|
# Apply proper slot detection (including clothing)
|
|
item_for_slots = {
|
|
"object_class": item.get("object_class"),
|
|
"coverage_mask": item.get("coverage", 0),
|
|
"name": item.get("name", ""),
|
|
"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()
|
|
]
|
|
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_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
|
|
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)})"
|
|
)
|
|
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"
|
|
)
|
|
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 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()]}"
|
|
)
|
|
|
|
# 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]
|
|
|
|
def _recursive_armor_search(self, slot_list, index, current_combo, used_items):
|
|
"""Recursive backtracking search (MagSuitbuilder approach)"""
|
|
# Base case: we've processed all slots
|
|
if index >= len(slot_list):
|
|
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
|
|
if not self._is_duplicate_combo(current_combo):
|
|
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
|
|
)
|
|
# Keep only top combinations
|
|
if len(self.combinations_found) > self.max_combinations * 2:
|
|
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:
|
|
# Check if this item would help meet set requirements
|
|
if self._should_try_item(item, current_combo):
|
|
# 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
|
|
)
|
|
|
|
# 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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
# 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
|
|
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)
|
|
if item_set_int == primary_set_int:
|
|
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:
|
|
score = 1000
|
|
elif item_set_int == secondary_set_int and secondary_count < 4:
|
|
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:
|
|
score = 800
|
|
elif item_set_int == secondary_set_int and secondary_count < 4:
|
|
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)
|
|
if item_set_int == primary_set_int:
|
|
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
|
|
]
|
|
if equipped_items:
|
|
# Take the best equipped item for this slot
|
|
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
|
|
)
|
|
|
|
if has_accessory_constraints:
|
|
# Systematically optimize jewelry slots
|
|
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
|
|
]
|
|
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.
|
|
Phase 3D.2 implementation.
|
|
"""
|
|
# 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
|
|
"Right Ring",
|
|
"Left Wrist", # Bracelets
|
|
"Right Wrist",
|
|
"Trinket", # Special items
|
|
]
|
|
|
|
# Track spells already covered by armor
|
|
covered_spells = set()
|
|
for item in suit["items"].values():
|
|
item_spells = item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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]:
|
|
best_item = self._find_best_jewelry_for_slot(
|
|
slot, jewelry_by_slot[slot], suit, covered_spells
|
|
)
|
|
if best_item:
|
|
suit["items"][slot] = best_item
|
|
|
|
# Update covered spells
|
|
item_spells = best_item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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": [],
|
|
}
|
|
|
|
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
|
|
):
|
|
"""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
|
|
)
|
|
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.
|
|
Similar to jewelry optimization but for clothing items.
|
|
"""
|
|
# 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():
|
|
item_spells = item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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]:
|
|
best_item = self._find_best_clothing_for_slot(
|
|
slot, clothing_by_slot[slot], suit, covered_spells
|
|
)
|
|
if best_item:
|
|
suit["items"][slot] = best_item
|
|
|
|
# Update covered spells
|
|
item_spells = best_item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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
|
|
):
|
|
"""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
|
|
)
|
|
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 _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", "")
|
|
if spell_data:
|
|
if isinstance(spell_data, str):
|
|
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
|
|
):
|
|
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", "")
|
|
if spell_data:
|
|
if isinstance(spell_data, str):
|
|
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
|
|
):
|
|
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_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
|
|
|
|
# Spell diversity bonus from jewelry
|
|
jewelry_spells = set()
|
|
for item in jewelry_items:
|
|
item_spells = item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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()
|
|
)
|
|
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
|
|
|
|
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
|
|
)
|
|
|
|
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_count": primary_count,
|
|
"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()
|
|
),
|
|
"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()
|
|
),
|
|
},
|
|
"items": {
|
|
slot: {
|
|
"character_name": item["character_name"],
|
|
"name": item["name"],
|
|
"item_set_id": item.get("item_set_id"),
|
|
"item_set_name": self._get_set_name(item.get("item_set_id")),
|
|
"armor_level": item.get("armor_level", 0),
|
|
"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),
|
|
}
|
|
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
|
|
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')}"
|
|
)
|
|
|
|
# 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",
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
if matches_all_words:
|
|
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 is_ward_spell:
|
|
# Ward spells are allowed to overlap - no penalty
|
|
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_note = f"Overlapping {requested} on {cantrip_sources[requested]} and {slot}"
|
|
overlap_notes.append(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)
|
|
else:
|
|
# No cantrip constraints - just collect all legendary cantrips for bonus scoring
|
|
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:
|
|
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
|
|
)
|
|
|
|
# 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()
|
|
)
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# BONUS: Required spell coverage (up to 50 points)
|
|
all_spells = set()
|
|
for item in items.values():
|
|
item_spells = item.get("spell_names", "")
|
|
if item_spells:
|
|
if isinstance(item_spells, str):
|
|
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
|
|
spell_coverage_count = 0
|
|
for required in required_spells:
|
|
for actual_spell in all_spells:
|
|
# 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
|
|
)
|
|
|
|
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"
|
|
)
|
|
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
|
|
)
|
|
|
|
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 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
|
|
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})"
|
|
)
|
|
|
|
# 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_count": primary_count,
|
|
"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
|
|
]
|
|
),
|
|
"total_armor": total_armor,
|
|
"total_crit_damage": total_crit_damage,
|
|
"total_damage_rating": total_damage_rating,
|
|
"total_heal_boost": total_heal_boost,
|
|
},
|
|
"items": {
|
|
slot: {
|
|
"character_name": item["character_name"],
|
|
"name": item["name"],
|
|
"item_set_id": item.get("item_set_id"),
|
|
"item_set_name": self._get_set_name(item.get("item_set_id")),
|
|
"armor_level": item.get("armor_level", 0),
|
|
"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),
|
|
}
|
|
for slot, item in items.items()
|
|
},
|
|
"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,
|
|
)
|
|
|
|
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", {})
|
|
|
|
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"
|
|
)
|
|
|
|
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()
|
|
if solution and self._is_unique_solution(solution, solutions):
|
|
solutions.append(solution)
|
|
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()
|
|
if solution and self._is_unique_solution(solution, solutions):
|
|
solutions.append(solution)
|
|
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()
|
|
if solution and self._is_unique_solution(solution, solutions):
|
|
solutions.append(solution)
|
|
if len(solutions) >= max_results * 2:
|
|
break
|
|
iteration += 1
|
|
|
|
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,
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
# 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"]
|
|
)
|
|
|
|
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:
|
|
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
|
|
)
|
|
|
|
# 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
|
|
]
|
|
|
|
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
|
|
):
|
|
item_value += 2000 # Very high bonus for needed primary pieces
|
|
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
|
|
]
|
|
|
|
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
|
|
]
|
|
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
|
|
|
|
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
|
|
if current_solution:
|
|
for existing_item in current_solution.get("items", {}).values():
|
|
existing_set = existing_item.get("item_set_id")
|
|
if self.primary_set and existing_set == self.primary_set:
|
|
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
|
|
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
|
|
value += 1200 + (4 - secondary_count) * 150 # Very high priority
|
|
else:
|
|
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
|
|
)
|
|
|
|
# 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
|
|
]
|
|
|
|
# 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
|
|
):
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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.
|
|
"""
|
|
# Convert items_by_slot to flat item list for easier processing
|
|
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,
|
|
)
|
|
|
|
# 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,
|
|
):
|
|
"""
|
|
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
|
|
primary_actual = stats["primary_set_count"]
|
|
if primary_actual >= primary_target:
|
|
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"]
|
|
if secondary_actual >= secondary_target:
|
|
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
|
|
if min_crit_damage and stats["total_crit_damage"] >= min_crit_damage:
|
|
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,
|
|
):
|
|
"""
|
|
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",
|
|
}
|
|
|
|
# Check primary set requirements
|
|
if primary_set:
|
|
primary_set_int = int(primary_set)
|
|
set_name = set_names.get(primary_set_int, f"Set {primary_set}")
|
|
needed = 5 - stats.get("primary_set_count", 0)
|
|
if needed > 0:
|
|
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)
|
|
set_name = set_names.get(secondary_set_int, f"Set {secondary_set}")
|
|
needed = 4 - stats.get("secondary_set_count", 0)
|
|
if needed > 0:
|
|
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)
|
|
total = len(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(",")
|
|
)
|
|
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()
|
|
):
|
|
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 ''}"
|
|
)
|
|
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)
|
|
if current_armor < min_armor:
|
|
shortfall = min_armor - current_armor
|
|
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)
|
|
if current_crit < min_crit_damage:
|
|
shortfall = min_crit_damage - current_crit
|
|
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})"
|
|
)
|
|
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})"
|
|
)
|
|
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"]
|
|
)
|
|
|
|
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)
|