MosswartOverlord/inventory-service/main.py
2025-06-19 17:46:19 +00:00

5558 lines
246 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 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
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
)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# FastAPI app
app = FastAPI(
title="Inventory Service",
description="Microservice for Asheron's Call item data processing and queries",
version="1.0.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 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()
# 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
# Startup/shutdown events
@app.on_event("startup")
async def startup():
"""Initialize database connection and create tables."""
await database.connect()
# Create tables if they don't exist
Base.metadata.create_all(engine)
# 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_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 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
# 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")
async def process_inventory(inventory: InventoryItem):
"""Process raw inventory data and store in normalized format."""
processed_count = 0
error_count = 0
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:
id_list = [str(row['id']) for row in item_ids]
id_placeholder = ','.join(id_list)
# Delete from all related tables first
await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_placeholder})")
await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_placeholder})")
# 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'],
# 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:
logger.error(f"Error processing item {item_data.get('Id', 'unknown')}: {e}")
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 {
"status": "completed",
"processed": processed_count,
"errors": error_count,
"total_received": len(inventory.items),
"character": inventory.character_name
}
@app.get("/inventory/{character_name}")
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.name, i.icon, i.object_class, i.value, i.burden,
i.has_id_data, i.timestamp,
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 = []
for item in items:
processed_item = dict(item)
# 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:
# 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'):
processed_item['current_mana'] = mana_info['current_mana']
if mana_info.get('max_mana'):
processed_item['max_mana'] = mana_info['max_mana']
# 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}
processed_items.append(processed_item)
return {
"character_name": character_name,
"item_count": len(processed_items),
"items": processed_items
}
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "inventory-service"}
@app.get("/sets/list")
async def list_equipment_sets():
"""Get all unique equipment set names 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 {
"equipment_sets": equipment_sets,
"total_sets": len(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")
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}")
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")
async def search_items(
# Text search
text: str = Query(None, description="Search item names, descriptions, or properties"),
character: str = Query(None, description="Limit search to specific character"),
characters: str = Query(None, description="Comma-separated list of character names"),
include_all_characters: bool = Query(False, description="Search across all characters"),
# Equipment filtering
equipment_status: str = Query(None, description="equipped, unequipped, or all"),
equipment_slot: int = Query(None, description="Equipment slot mask (e.g., 1=head, 512=chest)"),
# 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"),
# Spell filtering
has_spell: str = Query(None, description="Must have this specific spell (by name)"),
spell_contains: str = Query(None, description="Spell name contains this text"),
legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names"),
# 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"),
min_damage_rating: int = Query(None, description="Minimum damage rating"),
min_heal_boost_rating: int = Query(None, description="Minimum heal boost 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)"),
min_workmanship: float = Query(None, description="Minimum workmanship"),
has_imbue: bool = Query(None, description="Has imbue effects"),
item_set: str = Query(None, description="Item set ID (single set)"),
item_sets: str = Query(None, description="Comma-separated list of item set IDs"),
min_tinks: int = Query(None, description="Minimum tinker count"),
# 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=2000, description="Items per page")
):
"""
Search items across characters with comprehensive filtering options.
"""
try:
# Build base query - include raw data for comprehensive translations
query_parts = ["""
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,
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(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
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
"""]
conditions = []
params = {}
# Character filtering
if character:
conditions.append("i.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"i.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)
if text:
conditions.append("i.name ILIKE :text")
params["text"] = f"%{text}%"
# Item category filtering
if armor_only:
# Armor: ObjectClass 2 (Clothing) or 3 (Armor) with armor_level > 0
conditions.append("(i.object_class IN (2, 3) AND COALESCE(cs.armor_level, 0) > 0)")
elif jewelry_only:
# Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets
conditions.append("i.object_class = 4")
elif weapon_only:
# Weapons: ObjectClass 6 (MeleeWeapon), 7 (MissileWeapon), 8 (Caster) with max_damage > 0
conditions.append("(i.object_class IN (6, 7, 8) AND COALESCE(cs.max_damage, 0) > 0)")
# 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))
spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})")
logger.info(f"Found {len(matching_spell_ids)} matching spell 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("sp.spell_id = -1") # Use impossible condition instead of 1=0
# Combine spell conditions with OR - only add if we have conditions
if spell_conditions:
conditions.append(f"({' OR '.join(spell_conditions)})")
# Equipment status
if equipment_status == "equipped":
conditions.append("i.current_wielded_location > 0")
elif equipment_status == "unequipped":
conditions.append("i.current_wielded_location = 0")
# Equipment slot
if equipment_slot is not None:
conditions.append("i.current_wielded_location = :equipment_slot")
params["equipment_slot"] = equipment_slot
# Combat properties
if min_damage is not None:
conditions.append("cs.max_damage >= :min_damage")
params["min_damage"] = min_damage
if max_damage is not None:
conditions.append("cs.max_damage <= :max_damage")
params["max_damage"] = max_damage
if min_armor is not None:
conditions.append("cs.armor_level >= :min_armor")
params["min_armor"] = min_armor
if max_armor is not None:
conditions.append("cs.armor_level <= :max_armor")
params["max_armor"] = max_armor
if min_attack_bonus is not None:
conditions.append("cs.attack_bonus >= :min_attack_bonus")
params["min_attack_bonus"] = min_attack_bonus
if min_crit_damage_rating is not None:
# Check both individual rating (314) and gear total (374)
conditions.append("""(
COALESCE((rd.int_values->>'314')::int, 0) >= :min_crit_damage_rating OR
COALESCE((rd.int_values->>'374')::int, 0) >= :min_crit_damage_rating
)""")
params["min_crit_damage_rating"] = min_crit_damage_rating
if min_damage_rating is not None:
# Check both individual rating (307) and gear total (370)
conditions.append("""(
COALESCE((rd.int_values->>'307')::int, 0) >= :min_damage_rating OR
COALESCE((rd.int_values->>'370')::int, 0) >= :min_damage_rating
)""")
params["min_damage_rating"] = min_damage_rating
if min_heal_boost_rating is not None:
# Check both individual rating (323) and gear total (376)
conditions.append("""(
COALESCE((rd.int_values->>'323')::int, 0) >= :min_heal_boost_rating OR
COALESCE((rd.int_values->>'376')::int, 0) >= :min_heal_boost_rating
)""")
params["min_heal_boost_rating"] = min_heal_boost_rating
# Requirements
if max_level is not None:
conditions.append("(req.wield_level <= :max_level OR req.wield_level IS NULL)")
params["max_level"] = max_level
if min_level is not None:
conditions.append("req.wield_level >= :min_level")
params["min_level"] = min_level
# Enhancements
if material:
conditions.append("enh.material ILIKE :material")
params["material"] = f"%{material}%"
if min_workmanship is not None:
conditions.append("enh.workmanship >= :min_workmanship")
params["min_workmanship"] = min_workmanship
if has_imbue is not None:
if has_imbue:
conditions.append("enh.imbue IS NOT NULL AND enh.imbue != ''")
else:
conditions.append("(enh.imbue IS NULL OR enh.imbue = '')")
if item_set:
conditions.append("enh.item_set = :item_set")
params["item_set"] = item_set
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:
# Create parameterized IN clause
set_params = []
for i, set_id in enumerate(set_list):
param_name = f"set_{i}"
set_params.append(f":{param_name}")
params[param_name] = set_id
conditions.append(f"enh.item_set IN ({', '.join(set_params)})")
else:
# Empty sets list - no results
conditions.append("1 = 0")
if min_tinks is not None:
conditions.append("enh.tinks >= :min_tinks")
params["min_tinks"] = min_tinks
# Item state
if bonded is not None:
conditions.append("i.bonded > 0" if bonded else "i.bonded = 0")
if attuned is not None:
conditions.append("i.attuned > 0" if attuned else "i.attuned = 0")
if unique is not None:
conditions.append("i.unique = :unique")
params["unique"] = unique
if is_rare is not None:
if is_rare:
conditions.append("i.rare_id IS NOT NULL AND i.rare_id > 0")
else:
conditions.append("(i.rare_id IS NULL OR i.rare_id <= 0)")
if min_condition is not None:
conditions.append("((i.structure * 100.0 / NULLIF(i.max_structure, 0)) >= :min_condition OR i.max_structure IS NULL)")
params["min_condition"] = min_condition
# Value/utility
if min_value is not None:
conditions.append("i.value >= :min_value")
params["min_value"] = min_value
if max_value is not None:
conditions.append("i.value <= :max_value")
params["max_value"] = max_value
if max_burden is not None:
conditions.append("i.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": "i.name",
"value": "i.value",
"damage": "cs.max_damage",
"armor": "cs.armor_level",
"workmanship": "enh.workmanship",
"level": "req.wield_level",
"damage_rating": "damage_rating",
"crit_damage_rating": "crit_damage_rating",
"heal_boost_rating": "heal_boost_rating"
}
sort_field = sort_mapping.get(sort_by, "i.name")
sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC"
query_parts.append(f"ORDER BY {sort_field} {sort_direction}")
# 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 - build separate count query
count_query = """
SELECT COUNT(DISTINCT i.id)
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 += "\n LEFT JOIN item_spells sp ON i.id = sp.item_id"
if conditions:
count_query += "\nWHERE " + " AND ".join(conditions)
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}"
else:
item['coverage'] = None
# 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 {
"items": items,
"total_count": total_count,
"page": page,
"limit": limit,
"total_pages": (total_count + limit - 1) // limit,
"search_criteria": {
"text": text,
"character": character,
"include_all_characters": include_all_characters,
"equipment_status": equipment_status,
"filters_applied": len(conditions)
}
}
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")
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)
print(f"DEBUG: 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(
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 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")
# 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()
# 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 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) = 6
if 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)