major fixes for inventory

This commit is contained in:
erik 2025-07-02 10:29:36 +00:00
parent 00ef1d1f4b
commit 4d19e29847
10 changed files with 969 additions and 203 deletions

View file

@ -266,6 +266,17 @@ def derive_item_type_from_object_class(object_class: int, item_data: dict = None
# Fallback to "Misc" if ObjectClass not found in enum
return "Misc"
def translate_equipment_set_id(set_id: str) -> str:
"""Translate equipment set ID to set name using comprehensive database."""
dictionaries = ENUM_MAPPINGS.get('dictionaries', {})
attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {})
set_name = attribute_set_info.get(str(set_id))
if set_name:
return set_name
else:
# Fallback to just return the ID as string (matches database storage fallback)
return str(set_id)
def translate_object_class(object_class_id: int, item_data: dict = None) -> str:
"""Translate object class ID to human-readable name with context-aware detection."""
# Use the extracted ObjectClass enum first
@ -1011,6 +1022,28 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]:
'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), # CritDamageRating
'heal_boost_rating': int_values.get('323', int_values.get(323, -1)), # HealingBoostRating
# Missing critical ratings
'damage_resist_rating': int_values.get('308', int_values.get(308, -1)), # DamageResistRating
'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), # CritResistRating
'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), # CritDamageResistRating
'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), # HealingResistRating
'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), # NetherResistRating
'vitality_rating': int_values.get('341', int_values.get(341, -1)), # VitalityRating
'healing_rating': int_values.get('342', int_values.get(342, -1)), # LumAugHealingRating
'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), # DotResistRating
'life_resist_rating': int_values.get('351', int_values.get(351, -1)), # LifeResistRating
# Specialized ratings
'sneak_attack_rating': int_values.get('356', int_values.get(356, -1)), # SneakAttackRating
'recklessness_rating': int_values.get('357', int_values.get(357, -1)), # RecklessnessRating
'deception_rating': int_values.get('358', int_values.get(358, -1)), # DeceptionRating
# PvP ratings
'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), # PKDamageRating
'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), # PKDamageResistRating
'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), # GearPKDamageRating
'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), # GearPKDamageResistRating
# Additional ratings
'weakness_rating': int_values.get('329', int_values.get(329, -1)),
'nether_over_time': int_values.get('330', int_values.get(330, -1)),
@ -1731,6 +1764,7 @@ async def search_items(
# Equipment filtering
equipment_status: str = Query(None, description="equipped, unequipped, or all"),
equipment_slot: int = Query(None, description="Equipment slot mask (e.g., 1=head, 512=chest)"),
slot_names: str = Query(None, description="Comma-separated list of slot names (e.g., Head,Chest,Ring)"),
# Item category filtering
armor_only: bool = Query(False, description="Show only armor items"),
@ -1751,6 +1785,22 @@ async def search_items(
min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating"),
min_damage_rating: int = Query(None, description="Minimum damage rating"),
min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"),
min_vitality_rating: int = Query(None, description="Minimum vitality rating"),
min_damage_resist_rating: int = Query(None, description="Minimum damage resist rating"),
min_crit_resist_rating: int = Query(None, description="Minimum crit resist rating"),
min_crit_damage_resist_rating: int = Query(None, description="Minimum crit damage resist rating"),
min_healing_resist_rating: int = Query(None, description="Minimum healing resist rating"),
min_nether_resist_rating: int = Query(None, description="Minimum nether resist rating"),
min_healing_rating: int = Query(None, description="Minimum healing rating"),
min_dot_resist_rating: int = Query(None, description="Minimum DoT resist rating"),
min_life_resist_rating: int = Query(None, description="Minimum life resist rating"),
min_sneak_attack_rating: int = Query(None, description="Minimum sneak attack rating"),
min_recklessness_rating: int = Query(None, description="Minimum recklessness rating"),
min_deception_rating: int = Query(None, description="Minimum deception rating"),
min_pk_damage_rating: int = Query(None, description="Minimum PvP damage rating"),
min_pk_damage_resist_rating: int = Query(None, description="Minimum PvP damage resist rating"),
min_gear_pk_damage_rating: int = Query(None, description="Minimum gear PvP damage rating"),
min_gear_pk_damage_resist_rating: int = Query(None, description="Minimum gear PvP damage resist rating"),
# Requirements
max_level: int = Query(None, description="Maximum wield level requirement"),
@ -1780,60 +1830,153 @@ async def search_items(
sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"),
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(200, ge=1, le=2000, description="Items per page")
limit: int = Query(200, ge=1, le=10000, description="Items per page")
):
"""
Search items across characters with comprehensive filtering options.
"""
try:
# Build base query - include raw data for comprehensive translations
# Build base query with CTE for computed slot names
query_parts = ["""
SELECT DISTINCT
i.id as db_item_id,
i.character_name,
i.name,
i.icon,
i.object_class,
i.value,
i.burden,
i.current_wielded_location,
i.bonded,
i.attuned,
i.unique,
i.stack_size,
i.max_stack_size,
i.structure,
i.max_structure,
i.rare_id,
i.timestamp as last_updated,
COALESCE(cs.max_damage, -1) as max_damage,
COALESCE(cs.armor_level, -1) as armor_level,
COALESCE(cs.attack_bonus, -1.0) as attack_bonus,
GREATEST(
COALESCE((rd.int_values->>'314')::int, -1),
COALESCE((rd.int_values->>'374')::int, -1)
) as crit_damage_rating,
GREATEST(
COALESCE((rd.int_values->>'307')::int, -1),
COALESCE((rd.int_values->>'370')::int, -1)
) as damage_rating,
GREATEST(
COALESCE((rd.int_values->>'323')::int, -1),
COALESCE((rd.int_values->>'376')::int, -1)
) as heal_boost_rating,
COALESCE(req.wield_level, -1) as wield_level,
COALESCE(enh.material, '') as material,
COALESCE(enh.workmanship, -1.0) as workmanship,
COALESCE(enh.imbue, '') as imbue,
COALESCE(enh.tinks, -1) as tinks,
COALESCE(enh.item_set, '') as item_set,
rd.original_json
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements req ON i.id = req.item_id
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
WITH items_with_slots AS (
SELECT DISTINCT
i.id as db_item_id,
i.character_name,
i.name,
i.icon,
i.object_class,
i.value,
i.burden,
i.current_wielded_location,
i.bonded,
i.attuned,
i.unique,
i.stack_size,
i.max_stack_size,
i.structure,
i.max_structure,
i.rare_id,
i.timestamp as last_updated,
COALESCE(cs.max_damage, -1) as max_damage,
COALESCE(cs.armor_level, -1) as armor_level,
COALESCE(cs.attack_bonus, -1.0) as attack_bonus,
GREATEST(
COALESCE((rd.int_values->>'314')::int, -1),
COALESCE((rd.int_values->>'374')::int, -1)
) as crit_damage_rating,
GREATEST(
COALESCE((rd.int_values->>'307')::int, -1),
COALESCE((rd.int_values->>'370')::int, -1)
) as damage_rating,
GREATEST(
COALESCE((rd.int_values->>'323')::int, -1),
COALESCE((rd.int_values->>'376')::int, -1)
) as heal_boost_rating,
COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating,
COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating,
COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating,
COALESCE((rd.int_values->>'316')::int, -1) as crit_damage_resist_rating,
COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating,
COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating,
COALESCE((rd.int_values->>'342')::int, -1) as healing_rating,
COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating,
COALESCE((rd.int_values->>'351')::int, -1) as life_resist_rating,
COALESCE((rd.int_values->>'356')::int, -1) as sneak_attack_rating,
COALESCE((rd.int_values->>'357')::int, -1) as recklessness_rating,
COALESCE((rd.int_values->>'358')::int, -1) as deception_rating,
COALESCE((rd.int_values->>'381')::int, -1) as pk_damage_rating,
COALESCE((rd.int_values->>'382')::int, -1) as pk_damage_resist_rating,
COALESCE((rd.int_values->>'383')::int, -1) as gear_pk_damage_rating,
COALESCE((rd.int_values->>'384')::int, -1) as gear_pk_damage_resist_rating,
COALESCE(req.wield_level, -1) as wield_level,
COALESCE(enh.material, '') as material,
COALESCE(enh.workmanship, -1.0) as workmanship,
COALESCE(enh.imbue, '') as imbue,
COALESCE(enh.tinks, -1) as tinks,
COALESCE(enh.item_set, '') as item_set,
rd.original_json,
-- Compute slot_name in SQL
CASE
-- ARMOR/CLOTHING: Use EquipableSlots_Decal from JSON
WHEN rd.original_json IS NOT NULL
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
THEN
-- Translate equippable slots to readable names
CASE (rd.original_json->'IntValues'->>'218103822')::int
WHEN 1 THEN 'Head'
WHEN 2 THEN 'Neck'
WHEN 4 THEN 'Shirt'
WHEN 16 THEN 'Chest'
WHEN 32 THEN 'Hands'
WHEN 256 THEN 'Feet'
WHEN 512 THEN 'Chest'
WHEN 1024 THEN 'Abdomen'
WHEN 2048 THEN 'Upper Arms'
WHEN 4096 THEN 'Lower Arms'
WHEN 8192 THEN 'Upper Legs'
WHEN 16384 THEN 'Lower Legs'
WHEN 33554432 THEN 'Shield'
-- Multi-slot combinations
WHEN 15 THEN 'Chest, Abdomen, Upper Arms, Lower Arms' -- Robes
WHEN 30 THEN 'Shirt' -- Full shirts
WHEN 14336 THEN 'Chest, Abdomen, Upper Arms, Lower Arms' -- Hauberks
WHEN 25600 THEN 'Abdomen, Upper Legs, Lower Legs' -- Tassets
ELSE
-- For other combinations, decode bit flags
CONCAT_WS(', ',
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1 = 1 THEN 'Head' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 512 = 512 THEN 'Chest' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1024 = 1024 THEN 'Abdomen' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 2048 = 2048 THEN 'Upper Arms' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 4096 = 4096 THEN 'Lower Arms' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 32 = 32 THEN 'Hands' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 8192 = 8192 THEN 'Upper Legs' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 16384 = 16384 THEN 'Lower Legs' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 256 = 256 THEN 'Feet' END
)
END
-- JEWELRY: Check wielded location first, then name patterns
WHEN i.object_class = 4 THEN
CASE
-- Use wielded location if equipped
WHEN i.current_wielded_location = 32768 THEN 'Neck'
WHEN i.current_wielded_location = 262144 THEN 'Left Ring'
WHEN i.current_wielded_location = 524288 THEN 'Right Ring'
WHEN i.current_wielded_location = 786432 THEN 'Left Ring, Right Ring'
WHEN i.current_wielded_location = 131072 THEN 'Left Wrist'
WHEN i.current_wielded_location = 1048576 THEN 'Right Wrist'
WHEN i.current_wielded_location = 1179648 THEN 'Left Wrist, Right Wrist'
-- Fallback to name patterns for unequipped
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Left Ring, Right Ring'
WHEN i.name ILIKE '%bracelet%' THEN 'Left Wrist, Right Wrist'
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
ELSE 'Jewelry'
END
-- WEAPONS: Use object class
WHEN i.object_class = 6 THEN 'Melee Weapon'
WHEN i.object_class = 7 THEN 'Missile Weapon'
WHEN i.object_class = 8 THEN 'Held'
-- Check wielded location for two-handed weapons
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
-- DEFAULT
ELSE '-'
END as computed_slot_name
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements req ON i.id = req.item_id
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
)
SELECT * FROM items_with_slots
"""]
conditions = []
@ -1841,7 +1984,7 @@ async def search_items(
# Character filtering
if character:
conditions.append("i.character_name = :character")
conditions.append("character_name = :character")
params["character"] = character
elif characters:
# Handle comma-separated list of characters
@ -1853,7 +1996,7 @@ async def search_items(
param_name = f"char_{i}"
char_params.append(f":{param_name}")
params[param_name] = char_name
conditions.append(f"i.character_name IN ({', '.join(char_params)})")
conditions.append(f"character_name IN ({', '.join(char_params)})")
else:
return {
"error": "Empty characters list provided",
@ -1868,21 +2011,27 @@ async def search_items(
"total_count": 0
}
# Text search (name)
# Text search (name with material support)
if text:
conditions.append("i.name ILIKE :text")
# Search both the concatenated material+name and base name
# This handles searches like "Silver Celdon Girth" or just "silver" for material
conditions.append("""(
CONCAT(COALESCE(material, ''), ' ', name) ILIKE :text OR
name ILIKE :text OR
COALESCE(material, '') ILIKE :text
)""")
params["text"] = f"%{text}%"
# Item category filtering
if armor_only:
# Armor: ObjectClass 2 (Clothing) or 3 (Armor) with armor_level > 0
conditions.append("(i.object_class IN (2, 3) AND COALESCE(cs.armor_level, 0) > 0)")
conditions.append("(object_class IN (2, 3) AND COALESCE(armor_level, 0) > 0)")
elif jewelry_only:
# Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets
conditions.append("i.object_class = 4")
conditions.append("object_class = 4")
elif weapon_only:
# Weapons: ObjectClass 6 (MeleeWeapon), 7 (MissileWeapon), 8 (Caster) with max_damage > 0
conditions.append("(i.object_class IN (6, 7, 8) AND COALESCE(cs.max_damage, 0) > 0)")
conditions.append("(object_class IN (6, 7, 8) AND COALESCE(max_damage, 0) > 0)")
# Spell filtering - need to join with item_spells and use spell database
spell_join_added = False
@ -1953,133 +2102,236 @@ async def search_items(
if matching_spell_ids:
# Remove duplicates
matching_spell_ids = list(set(matching_spell_ids))
spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})")
logger.info(f"Found {len(matching_spell_ids)} matching spell IDs: {matching_spell_ids}")
# CONSTRAINT SATISFACTION: Items must have ALL selected spells (AND logic)
# Use EXISTS subquery to ensure all required spells are present
num_required_spells = len(cantrip_names) # Number of different cantrips user selected
spell_conditions.append(f"""(
SELECT COUNT(DISTINCT sp2.spell_id)
FROM item_spells sp2
WHERE sp2.item_id = db_item_id
AND sp2.spell_id IN ({','.join(map(str, matching_spell_ids))})
) >= {num_required_spells}""")
logger.info(f"Constraint satisfaction: Items must have ALL {num_required_spells} cantrips from IDs: {matching_spell_ids}")
else:
# If no matching cantrips found, this will return no results
logger.warning("No matching spells found for any cantrips - search will return empty results")
spell_conditions.append("sp.spell_id = -1") # Use impossible condition instead of 1=0
spell_conditions.append("1 = 0") # Use impossible condition
# Combine spell conditions with OR - only add if we have conditions
# Add spell constraints (now using AND logic for constraint satisfaction)
if spell_conditions:
conditions.append(f"({' OR '.join(spell_conditions)})")
conditions.extend(spell_conditions)
# Equipment status
if equipment_status == "equipped":
conditions.append("i.current_wielded_location > 0")
conditions.append("current_wielded_location > 0")
elif equipment_status == "unequipped":
conditions.append("i.current_wielded_location = 0")
conditions.append("current_wielded_location = 0")
# Equipment slot
if equipment_slot is not None:
conditions.append("i.current_wielded_location = :equipment_slot")
conditions.append("current_wielded_location = :equipment_slot")
params["equipment_slot"] = equipment_slot
# Slot names filtering - use multiple approaches for better coverage
if slot_names:
slot_list = [slot.strip() for slot in slot_names.split(',') if slot.strip()]
if slot_list:
slot_conditions = []
for i, slot_name in enumerate(slot_list):
param_name = f"slot_{i}"
# Multiple filtering approaches for better coverage
slot_approaches = []
# Approach 1: Check computed_slot_name
if slot_name.lower() == 'ring':
slot_approaches.append("(computed_slot_name ILIKE '%Ring%')")
elif slot_name.lower() in ['bracelet', 'wrist']:
slot_approaches.append("(computed_slot_name ILIKE '%Wrist%')")
else:
slot_approaches.append(f"(computed_slot_name ILIKE :{param_name})")
params[param_name] = f"%{slot_name}%"
# Approach 2: Specific jewelry logic for common cases
if slot_name.lower() == 'neck':
# For neck: object_class = 4 (jewelry) with amulet/necklace/gorget names
slot_approaches.append("(object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%'))")
elif slot_name.lower() == 'ring':
# For rings: object_class = 4 with ring in name (excluding keyrings)
slot_approaches.append("(object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%')")
elif slot_name.lower() in ['bracelet', 'wrist']:
# For bracelets: object_class = 4 with bracelet in name
slot_approaches.append("(object_class = 4 AND name ILIKE '%bracelet%')")
elif slot_name.lower() == 'trinket':
# For trinkets: multiple approaches
# 1. Equipped trinkets have specific wielded_location
slot_approaches.append("(current_wielded_location = 67108864)")
# 2. Jewelry with trinket-related names
slot_approaches.append("(object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%'))")
# 3. Gems with trinket names
slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')")
# 4. Jewelry fallback: items that don't match other jewelry patterns
slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')")
# Combine approaches with OR (any approach can match)
if slot_approaches:
slot_conditions.append(f"({' OR '.join(slot_approaches)})")
# Use OR logic for slots (items matching ANY selected slot)
if slot_conditions:
conditions.append(f"({' OR '.join(slot_conditions)})")
logger.info(f"Slot filtering: Looking for items with slots: {slot_list}")
# Combat properties
if min_damage is not None:
conditions.append("cs.max_damage >= :min_damage")
conditions.append("max_damage >= :min_damage")
params["min_damage"] = min_damage
if max_damage is not None:
conditions.append("cs.max_damage <= :max_damage")
conditions.append("max_damage <= :max_damage")
params["max_damage"] = max_damage
if min_armor is not None:
conditions.append("cs.armor_level >= :min_armor")
conditions.append("armor_level >= :min_armor")
params["min_armor"] = min_armor
if max_armor is not None:
conditions.append("cs.armor_level <= :max_armor")
conditions.append("armor_level <= :max_armor")
params["max_armor"] = max_armor
if min_attack_bonus is not None:
conditions.append("cs.attack_bonus >= :min_attack_bonus")
conditions.append("attack_bonus >= :min_attack_bonus")
params["min_attack_bonus"] = min_attack_bonus
if min_crit_damage_rating is not None:
# Check both individual rating (314) and gear total (374)
conditions.append("""(
COALESCE((rd.int_values->>'314')::int, 0) >= :min_crit_damage_rating OR
COALESCE((rd.int_values->>'374')::int, 0) >= :min_crit_damage_rating
)""")
conditions.append("crit_damage_rating >= :min_crit_damage_rating")
params["min_crit_damage_rating"] = min_crit_damage_rating
if min_damage_rating is not None:
# Check both individual rating (307) and gear total (370)
conditions.append("""(
COALESCE((rd.int_values->>'307')::int, 0) >= :min_damage_rating OR
COALESCE((rd.int_values->>'370')::int, 0) >= :min_damage_rating
)""")
conditions.append("damage_rating >= :min_damage_rating")
params["min_damage_rating"] = min_damage_rating
if min_heal_boost_rating is not None:
# Check both individual rating (323) and gear total (376)
conditions.append("""(
COALESCE((rd.int_values->>'323')::int, 0) >= :min_heal_boost_rating OR
COALESCE((rd.int_values->>'376')::int, 0) >= :min_heal_boost_rating
)""")
conditions.append("heal_boost_rating >= :min_heal_boost_rating")
params["min_heal_boost_rating"] = min_heal_boost_rating
if min_vitality_rating is not None:
conditions.append("vitality_rating >= :min_vitality_rating")
params["min_vitality_rating"] = min_vitality_rating
if min_damage_resist_rating is not None:
conditions.append("damage_resist_rating >= :min_damage_resist_rating")
params["min_damage_resist_rating"] = min_damage_resist_rating
if min_crit_resist_rating is not None:
conditions.append("crit_resist_rating >= :min_crit_resist_rating")
params["min_crit_resist_rating"] = min_crit_resist_rating
if min_crit_damage_resist_rating is not None:
conditions.append("crit_damage_resist_rating >= :min_crit_damage_resist_rating")
params["min_crit_damage_resist_rating"] = min_crit_damage_resist_rating
if min_healing_resist_rating is not None:
conditions.append("healing_resist_rating >= :min_healing_resist_rating")
params["min_healing_resist_rating"] = min_healing_resist_rating
if min_nether_resist_rating is not None:
conditions.append("nether_resist_rating >= :min_nether_resist_rating")
params["min_nether_resist_rating"] = min_nether_resist_rating
if min_healing_rating is not None:
conditions.append("healing_rating >= :min_healing_rating")
params["min_healing_rating"] = min_healing_rating
if min_dot_resist_rating is not None:
conditions.append("dot_resist_rating >= :min_dot_resist_rating")
params["min_dot_resist_rating"] = min_dot_resist_rating
if min_life_resist_rating is not None:
conditions.append("life_resist_rating >= :min_life_resist_rating")
params["min_life_resist_rating"] = min_life_resist_rating
if min_sneak_attack_rating is not None:
conditions.append("sneak_attack_rating >= :min_sneak_attack_rating")
params["min_sneak_attack_rating"] = min_sneak_attack_rating
if min_recklessness_rating is not None:
conditions.append("recklessness_rating >= :min_recklessness_rating")
params["min_recklessness_rating"] = min_recklessness_rating
if min_deception_rating is not None:
conditions.append("deception_rating >= :min_deception_rating")
params["min_deception_rating"] = min_deception_rating
if min_pk_damage_rating is not None:
conditions.append("pk_damage_rating >= :min_pk_damage_rating")
params["min_pk_damage_rating"] = min_pk_damage_rating
if min_pk_damage_resist_rating is not None:
conditions.append("pk_damage_resist_rating >= :min_pk_damage_resist_rating")
params["min_pk_damage_resist_rating"] = min_pk_damage_resist_rating
if min_gear_pk_damage_rating is not None:
conditions.append("gear_pk_damage_rating >= :min_gear_pk_damage_rating")
params["min_gear_pk_damage_rating"] = min_gear_pk_damage_rating
if min_gear_pk_damage_resist_rating is not None:
conditions.append("gear_pk_damage_resist_rating >= :min_gear_pk_damage_resist_rating")
params["min_gear_pk_damage_resist_rating"] = min_gear_pk_damage_resist_rating
# Requirements
if max_level is not None:
conditions.append("(req.wield_level <= :max_level OR req.wield_level IS NULL)")
conditions.append("(wield_level <= :max_level OR wield_level IS NULL)")
params["max_level"] = max_level
if min_level is not None:
conditions.append("req.wield_level >= :min_level")
conditions.append("wield_level >= :min_level")
params["min_level"] = min_level
# Enhancements
if material:
conditions.append("enh.material ILIKE :material")
conditions.append("material ILIKE :material")
params["material"] = f"%{material}%"
if min_workmanship is not None:
conditions.append("enh.workmanship >= :min_workmanship")
conditions.append("workmanship >= :min_workmanship")
params["min_workmanship"] = min_workmanship
if has_imbue is not None:
if has_imbue:
conditions.append("enh.imbue IS NOT NULL AND enh.imbue != ''")
conditions.append("imbue IS NOT NULL AND imbue != ''")
else:
conditions.append("(enh.imbue IS NULL OR enh.imbue = '')")
conditions.append("(imbue IS NULL OR imbue = '')")
if item_set:
conditions.append("enh.item_set = :item_set")
params["item_set"] = item_set
# Translate set ID to set name for database matching
set_name = translate_equipment_set_id(item_set)
logger.info(f"Translated equipment set ID '{item_set}' to name '{set_name}'")
conditions.append("item_set = :item_set")
params["item_set"] = set_name
elif item_sets:
# Handle comma-separated list of item set IDs
set_list = [set_id.strip() for set_id in item_sets.split(',') if set_id.strip()]
if set_list:
# Create parameterized IN clause
set_params = []
for i, set_id in enumerate(set_list):
param_name = f"set_{i}"
set_params.append(f":{param_name}")
params[param_name] = set_id
conditions.append(f"enh.item_set IN ({', '.join(set_params)})")
# CONSTRAINT SATISFACTION: Multiple equipment sets
if len(set_list) > 1:
# IMPOSSIBLE CONSTRAINT: Item cannot be in multiple sets simultaneously
logger.info(f"Multiple equipment sets selected: {set_list} - This is impossible, returning no results")
conditions.append("1 = 0") # No results for impossible constraint
else:
# Single set selection - normal behavior
set_id = set_list[0]
set_name = translate_equipment_set_id(set_id)
logger.info(f"Translated equipment set ID '{set_id}' to name '{set_name}'")
conditions.append("item_set = :item_set")
params["item_set"] = set_name
else:
# Empty sets list - no results
conditions.append("1 = 0")
if min_tinks is not None:
conditions.append("enh.tinks >= :min_tinks")
conditions.append("tinks >= :min_tinks")
params["min_tinks"] = min_tinks
# Item state
if bonded is not None:
conditions.append("i.bonded > 0" if bonded else "i.bonded = 0")
conditions.append("bonded > 0" if bonded else "bonded = 0")
if attuned is not None:
conditions.append("i.attuned > 0" if attuned else "i.attuned = 0")
conditions.append("attuned > 0" if attuned else "attuned = 0")
if unique is not None:
conditions.append("i.unique = :unique")
conditions.append("unique = :unique")
params["unique"] = unique
if is_rare is not None:
if is_rare:
conditions.append("i.rare_id IS NOT NULL AND i.rare_id > 0")
conditions.append("rare_id IS NOT NULL AND rare_id > 0")
else:
conditions.append("(i.rare_id IS NULL OR i.rare_id <= 0)")
conditions.append("(rare_id IS NULL OR rare_id <= 0)")
if min_condition is not None:
conditions.append("((i.structure * 100.0 / NULLIF(i.max_structure, 0)) >= :min_condition OR i.max_structure IS NULL)")
conditions.append("((structure * 100.0 / NULLIF(max_structure, 0)) >= :min_condition OR max_structure IS NULL)")
params["min_condition"] = min_condition
# Value/utility
if min_value is not None:
conditions.append("i.value >= :min_value")
conditions.append("value >= :min_value")
params["min_value"] = min_value
if max_value is not None:
conditions.append("i.value <= :max_value")
conditions.append("value <= :max_value")
params["max_value"] = max_value
if max_burden is not None:
conditions.append("i.burden <= :max_burden")
conditions.append("burden <= :max_burden")
params["max_burden"] = max_burden
# Build WHERE clause
@ -2088,17 +2340,19 @@ async def search_items(
# Add ORDER BY
sort_mapping = {
"name": "i.name",
"value": "i.value",
"damage": "cs.max_damage",
"armor": "cs.armor_level",
"workmanship": "enh.workmanship",
"level": "req.wield_level",
"name": "name",
"value": "value",
"damage": "max_damage",
"armor": "armor_level",
"workmanship": "workmanship",
"level": "wield_level",
"damage_rating": "damage_rating",
"crit_damage_rating": "crit_damage_rating",
"heal_boost_rating": "heal_boost_rating"
"heal_boost_rating": "heal_boost_rating",
"vitality_rating": "vitality_rating",
"damage_resist_rating": "damage_resist_rating"
}
sort_field = sort_mapping.get(sort_by, "i.name")
sort_field = sort_mapping.get(sort_by, "name")
sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC"
query_parts.append(f"ORDER BY {sort_field} {sort_direction}")
@ -2110,23 +2364,112 @@ async def search_items(
query = "\n".join(query_parts)
rows = await database.fetch_all(query, params)
# Get total count for pagination - build separate count query
count_query = """
SELECT COUNT(DISTINCT i.id)
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements req ON i.id = req.item_id
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
"""
# Get total count for pagination - use same CTE structure
count_query_parts = ["""
WITH items_with_slots AS (
SELECT DISTINCT
i.id as db_item_id,
i.character_name,
i.name,
i.object_class,
i.value,
i.burden,
i.current_wielded_location,
i.bonded,
i.attuned,
i.unique,
i.structure,
i.max_structure,
i.rare_id,
COALESCE(cs.max_damage, -1) as max_damage,
COALESCE(cs.armor_level, -1) as armor_level,
GREATEST(
COALESCE((rd.int_values->>'314')::int, -1),
COALESCE((rd.int_values->>'374')::int, -1)
) as crit_damage_rating,
GREATEST(
COALESCE((rd.int_values->>'307')::int, -1),
COALESCE((rd.int_values->>'370')::int, -1)
) as damage_rating,
GREATEST(
COALESCE((rd.int_values->>'323')::int, -1),
COALESCE((rd.int_values->>'376')::int, -1)
) as heal_boost_rating,
COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating,
COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating,
COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating,
COALESCE((rd.int_values->>'316')::int, -1) as crit_damage_resist_rating,
COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating,
COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating,
COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating,
COALESCE((rd.int_values->>'351')::int, -1) as life_resist_rating,
COALESCE(req.wield_level, -1) as wield_level,
COALESCE(enh.material, '') as material,
COALESCE(enh.workmanship, -1.0) as workmanship,
COALESCE(enh.imbue, '') as imbue,
COALESCE(enh.tinks, -1) as tinks,
COALESCE(enh.item_set, '') as item_set,
-- Same computed slot logic for count query
CASE
WHEN rd.original_json IS NOT NULL
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
THEN
CASE (rd.original_json->'IntValues'->>'218103822')::int
WHEN 1 THEN 'Head'
WHEN 2 THEN 'Neck'
WHEN 4 THEN 'Shirt'
WHEN 16 THEN 'Chest'
WHEN 32 THEN 'Hands'
WHEN 256 THEN 'Feet'
WHEN 512 THEN 'Chest'
WHEN 1024 THEN 'Abdomen'
WHEN 2048 THEN 'Upper Arms'
WHEN 4096 THEN 'Lower Arms'
WHEN 8192 THEN 'Upper Legs'
WHEN 16384 THEN 'Lower Legs'
WHEN 33554432 THEN 'Shield'
ELSE 'Armor'
END
WHEN i.object_class = 4 THEN
CASE
WHEN i.current_wielded_location = 32768 THEN 'Neck'
WHEN i.current_wielded_location IN (262144, 524288, 786432) THEN 'Ring'
WHEN i.current_wielded_location IN (131072, 1048576, 1179648) THEN 'Bracelet'
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Ring'
WHEN i.name ILIKE '%bracelet%' THEN 'Bracelet'
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
ELSE 'Jewelry'
END
WHEN i.object_class = 6 THEN 'Melee Weapon'
WHEN i.object_class = 7 THEN 'Missile Weapon'
WHEN i.object_class = 8 THEN 'Held'
ELSE '-'
END as computed_slot_name
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements req ON i.id = req.item_id
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
"""]
# Add spell join to count query if needed
if spell_join_added:
count_query += "\n LEFT JOIN item_spells sp ON i.id = sp.item_id"
count_query_parts.append("LEFT JOIN item_spells sp ON i.id = sp.item_id")
count_query_parts.append("""
)
SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots
""")
if conditions:
count_query += "\nWHERE " + " AND ".join(conditions)
count_query_parts.append("WHERE " + " AND ".join(conditions))
count_query = "\n".join(count_query_parts)
count_result = await database.fetch_one(count_query, params)
total_count = int(count_result[0]) if count_result else 0