From 1febf6e918ff5a0333be9726de2e12ae47718e39 Mon Sep 17 00:00:00 2001 From: erik Date: Sun, 15 Jun 2025 08:25:22 +0000 Subject: [PATCH] Fix suit building constraint satisfaction logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite armor and accessory filtering to only include items that contribute to constraints - Update jewelry and clothing scoring to reject items that don't meet constraints - Modify suit completion to leave slots empty instead of filling with non-contributing items - Update scoring to heavily penalize suits that don't meet specified requirements - Items must now meet set, spell, or stat constraints to be considered for suits - Empty slots are now preferred over items that don't help with constraints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- inventory-service/main.py | 3076 ++++++++++++++++++++++++++++++++++++- 1 file changed, 3072 insertions(+), 4 deletions(-) diff --git a/inventory-service/main.py b/inventory-service/main.py index 6745473e..17e05b5a 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -5,6 +5,7 @@ Handles enum translation, data normalization, and provides structured item data """ import json +import time import logging from pathlib import Path from typing import Dict, List, Optional, Any @@ -12,7 +13,8 @@ from datetime import datetime from fastapi import FastAPI, HTTPException, Depends, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse +from sse_starlette.sse import EventSourceResponse from pydantic import BaseModel import databases import sqlalchemy as sa @@ -362,7 +364,8 @@ def translate_coverage_mask(coverage_value: int) -> List[str]: 'Head': 'Head', 'Hands': 'Hands', 'Feet': 'Feet', - 'Cloak': 'Cloak' + 'Cloak': 'Cloak', + 'Robe': 'Robe' } return [name_mapping.get(part, part) for part in covered_parts if part] @@ -509,6 +512,7 @@ def convert_slot_name_to_friendly(slot_name: str) -> str: 'TwoHanded': 'Two-Handed', 'TrinketOne': 'Trinket', 'Cloak': 'Cloak', + 'Robe': 'Robe', 'SigilOne': 'Aetheria Blue', 'SigilTwo': 'Aetheria Yellow', 'SigilThree': 'Aetheria Red' @@ -564,7 +568,8 @@ def translate_equipment_slot(wielded_location: int) -> str: 'Held': 'Held', 'TwoHanded': 'Two-Handed', 'TrinketOne': 'Trinket', - 'Cloak': 'Cloak' + 'Cloak': 'Cloak', + 'Robe': 'Robe' } return name_mapping.get(slot_name, slot_name) @@ -604,7 +609,8 @@ def translate_equipment_slot(wielded_location: int) -> str: 'Held': 'Held', 'TwoHanded': 'Two-Handed', 'TrinketOne': 'Trinket', - 'Cloak': 'Cloak' + 'Cloak': 'Cloak', + 'Robe': 'Robe' } translated_parts = [name_mapping.get(part, part) for part in slot_parts] @@ -2424,6 +2430,3068 @@ async def list_inventory_characters(): raise HTTPException(status_code=500, detail="Failed to list inventory characters") +@app.get("/search/by-slot") +async def search_items_by_slot( + slot: str = Query(..., description="Slot to search for (e.g., 'Head', 'Chest', 'Hands')"), + characters: str = Query(None, description="Comma-separated list of character names"), + 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 translate_equipment_slot(slot_option): + """ + Translate equipment slot option to human-readable slot name. + This handles the slot options returned by get_sophisticated_slot_options. + """ + # Equipment slot mappings based on EquipMask values + slot_mappings = { + 1: "Head", + 2: "Chest", + 4: "Abdomen", + 8: "Upper Arms", + 16: "Lower Arms", + 32: "Hands", + 64: "Upper Legs", + 128: "Lower Legs", + 256: "Feet", + 512: "Shield", + 1024: "Neck", + 2048: "Left Wrist", + 4096: "Right Wrist", + 8192: "Left Ring", + 16384: "Right Ring", + 32768: "Trinket" + } + + return slot_mappings.get(slot_option, f"Slot_{slot_option}") + + +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)