5559 lines
246 KiB
Python
5559 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,
|
|
i.timestamp as last_updated,
|
|
COALESCE(cs.max_damage, -1) as max_damage,
|
|
COALESCE(cs.armor_level, -1) as armor_level,
|
|
COALESCE(cs.attack_bonus, -1.0) as attack_bonus,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'314')::int, -1),
|
|
COALESCE((rd.int_values->>'374')::int, -1)
|
|
) as crit_damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'307')::int, -1),
|
|
COALESCE((rd.int_values->>'370')::int, -1)
|
|
) as damage_rating,
|
|
GREATEST(
|
|
COALESCE((rd.int_values->>'323')::int, -1),
|
|
COALESCE((rd.int_values->>'376')::int, -1)
|
|
) as heal_boost_rating,
|
|
COALESCE(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)
|