From 4d19e29847bcdf5daed27ac1913e0c9b9f391afd Mon Sep 17 00:00:00 2001 From: erik Date: Wed, 2 Jul 2025 10:29:36 +0000 Subject: [PATCH] major fixes for inventory --- discord-rare-monitor/discord_rare_monitor.py | 57 +- docker-compose.yml | 2 + inventory-service/database.py | 18 + inventory-service/main.py | 599 +++++++++++++++---- main.py | 2 +- static/index.html | 7 + static/inventory.html | 284 ++++++++- static/inventory.js | 167 +++++- static/script.js | 8 + static/style.css | 28 + 10 files changed, 969 insertions(+), 203 deletions(-) diff --git a/discord-rare-monitor/discord_rare_monitor.py b/discord-rare-monitor/discord_rare_monitor.py index c314f31a..849d3963 100644 --- a/discord-rare-monitor/discord_rare_monitor.py +++ b/discord-rare-monitor/discord_rare_monitor.py @@ -418,9 +418,6 @@ class DiscordRareMonitor: # Post to Discord await self.post_rare_to_discord(data, rare_type) - # DEBUG: Also post rare info to aclog for monitoring classification - await self.post_rare_debug_to_aclog(rare_name, rare_type, character_name) - logger.info(f"📬 Posted to Discord: {rare_name} ({rare_type}) from {character_name}") except Exception as e: @@ -508,20 +505,24 @@ class DiscordRareMonitor: logger.error(f"❌ Could not find channel {ACLOG_CHANNEL_ID}") except Exception as e: logger.error(f"💥 VORTEX WARNING POSTING FAILED: {e}", exc_info=True) + + # Also post the original vortex message to sawatolife (raw format) + try: + await self.post_chat_to_discord(data) + logger.info(f"🌪️ Posted raw vortex message to sawatolife") + except Exception as e: + logger.error(f"❌ Error posting vortex to sawatolife: {e}") + return elif "whirlwind of vortex" in chat_text.lower(): logger.debug(f"🌪️ Found 'whirlwind of vortex' but not exact match in: {repr(chat_text)}") elif "jeebus" in chat_text.lower(): logger.debug(f"👀 Found 'jeebus' but not vortex pattern in: {repr(chat_text)}") - # Skip if this message contains any rare names (common or great) - if RARE_IN_CHAT_PATTERN.search(chat_text) or self.is_rare_message(chat_text): - logger.debug(f"🎯 Skipping rare message from {character_name}: {chat_text}") - return - - # Regular chat logging + # Log all chat messages (no longer filtering rares) logger.info(f"💬 Chat from {character_name}: {chat_text}") + # Post to AC Log channel await self.post_chat_to_discord(data) @@ -660,6 +661,21 @@ class DiscordRareMonitor: except Exception as e: logger.error(f"❌ Error posting to Discord: {e}") + def clean_and_format_chat_message(self, chat_text: str) -> str: + """Clean chat message by removing 'Dunking Rares:' prefix and add color coding.""" + try: + # Remove "Dunking Rares: " prefix if present + cleaned_text = chat_text + if cleaned_text.startswith("Dunking Rares: "): + cleaned_text = cleaned_text[15:] # Remove "Dunking Rares: " + + # Return cleaned text without any special formatting + return cleaned_text + + except Exception as e: + logger.warning(f"⚠️ Error cleaning chat message: {e}") + return chat_text # Return original on error + async def post_chat_to_discord(self, data: dict): """Post chat message to sawatolife Discord channel.""" try: @@ -679,9 +695,11 @@ class DiscordRareMonitor: except ValueError: timestamp = datetime.now() - # Create simple message format similar to your old bot + # Clean and format the message time_str = timestamp.strftime("%H:%M:%S") - message_content = f"`{time_str}` **{character_name}**: {chat_text}" + cleaned_chat = self.clean_and_format_chat_message(chat_text) + message_content = f"`{time_str}` {cleaned_chat}" + # Get sawatolife channel channel = self.client.get_channel(SAWATOLIFE_CHANNEL_ID) @@ -695,23 +713,6 @@ class DiscordRareMonitor: except Exception as e: logger.error(f"❌ Error posting chat to Discord: {e}") - async def post_rare_debug_to_aclog(self, rare_name: str, rare_type: str, character_name: str): - """Post rare classification debug info to sawatolife channel.""" - try: - # Create debug message showing classification - debug_message = f"🔍 **RARE DEBUG**: `{rare_name}` → **{rare_type.upper()}** (found by {character_name})" - - # Get sawatolife channel - channel = self.client.get_channel(SAWATOLIFE_CHANNEL_ID) - - if channel: - await channel.send(debug_message) - logger.debug(f"📤 Posted rare debug to #{channel.name}: {rare_name} -> {rare_type}") - else: - logger.error(f"❌ Could not find sawatolife channel for debug: {SAWATOLIFE_CHANNEL_ID}") - - except Exception as e: - logger.error(f"❌ Error posting rare debug to Discord: {e}") async def post_vortex_warning_to_discord(self, data: dict): """Post vortex warning as a special alert message to Discord.""" diff --git a/docker-compose.yml b/docker-compose.yml index f40ba32e..1f04d940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,8 @@ services: build: ./discord-rare-monitor depends_on: - dereth-tracker + volumes: + - "./discord-rare-monitor:/app" environment: DISCORD_RARE_BOT_TOKEN: "${DISCORD_RARE_BOT_TOKEN}" DERETH_TRACKER_WS_URL: "ws://dereth-tracker:8765/ws/live" diff --git a/inventory-service/database.py b/inventory-service/database.py index 0f60c8a2..64f3c457 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -190,10 +190,28 @@ class ItemRatings(Base): # Utility ratings heal_boost_rating = Column(Integer) vitality_rating = Column(Integer) + healing_rating = Column(Integer) # Healing rating (enum 342) mana_conversion_rating = Column(Integer) weakness_rating = Column(Integer) # Weakness debuff strength nether_over_time = Column(Integer) # Nether DoT + # Resist ratings + healing_resist_rating = Column(Integer) # Healing resist (enum 317) + nether_resist_rating = Column(Integer) # Nether resist (enum 331) + dot_resist_rating = Column(Integer) # DoT resist (enum 350) + life_resist_rating = Column(Integer) # Life resist (enum 351) + + # Specialized ratings + sneak_attack_rating = Column(Integer) # Sneak attack (enum 356) + recklessness_rating = Column(Integer) # Recklessness (enum 357) + deception_rating = Column(Integer) # Deception (enum 358) + + # PvP ratings + pk_damage_rating = Column(Integer) # PvP damage (enum 381) + pk_damage_resist_rating = Column(Integer) # PvP damage resist (enum 382) + gear_pk_damage_rating = Column(Integer) # Gear PvP damage (enum 383) + gear_pk_damage_resist_rating = Column(Integer) # Gear PvP damage resist (enum 384) + # Gear totals gear_damage = Column(Integer) # Total gear damage gear_damage_resist = Column(Integer) # Total gear damage resist diff --git a/inventory-service/main.py b/inventory-service/main.py index e5d2e91f..051f0eee 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -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 diff --git a/main.py b/main.py index 31309e0b..6cd4df0c 100644 --- a/main.py +++ b/main.py @@ -796,7 +796,7 @@ class TelemetrySnapshot(BaseModel): deaths: int total_deaths: Optional[int] = None # Removed from telemetry payload; always enforced to 0 and tracked via rare events - rares_found: int = 0 + rares_found: Optional[int] = 0 prismatic_taper_count: int vt_state: str # Optional telemetry metrics diff --git a/static/index.html b/static/index.html index 26a715ed..066658b7 100644 --- a/static/index.html +++ b/static/index.html @@ -88,6 +88,13 @@ + + +
diff --git a/static/inventory.html b/static/inventory.html index 93f7624a..cbc03c09 100644 --- a/static/inventory.html +++ b/static/inventory.html @@ -170,6 +170,14 @@ color: #000; } + .subsection-label { + font-weight: bold; + margin-bottom: 2px; + display: block; + font-size: 9px; + color: #333; + } + .search-actions { display: flex; gap: 5px; @@ -325,6 +333,79 @@ .text-right { text-align: right; } + + /* Column width control */ + .narrow-col { + width: 120px; + max-width: 120px; + font-size: 9px; + line-height: 1.1; + } + + .medium-col { + width: 150px; + max-width: 150px; + } + + .set-col { + width: 100px; + max-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .spells-cell { + font-size: 9px; + line-height: 1.2; + } + + .legendary-cantrip { + color: #ffd700; + font-weight: bold; + } + + .regular-spell { + color: #88ccff; + } + + /* Pagination controls */ + .pagination-controls { + padding: 12px 16px; + text-align: center; + background: #f8f8f8; + border-top: 1px solid #ddd; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 11px; + } + + .pagination-controls button { + padding: 4px 8px; + border: 1px solid #999; + background: #e0e0e0; + font-size: 10px; + cursor: pointer; + border-radius: 3px; + } + + .pagination-controls button:hover:not(:disabled) { + background: #d0d0d0; + } + + .pagination-controls button:disabled { + background: #f0f0f0; + color: #999; + cursor: not-allowed; + } + + .pagination-info { + font-weight: bold; + color: #333; + margin: 0 15px; + } @@ -442,6 +523,18 @@ + + +
+
+ + +
+
+ + +
+
@@ -545,54 +638,148 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
+ +
- - + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ +
@@ -636,6 +823,77 @@ + +
+ + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/static/inventory.js b/static/inventory.js index 7f74e77b..77469a81 100644 --- a/static/inventory.js +++ b/static/inventory.js @@ -20,6 +20,11 @@ let currentSort = { // Store current search results for client-side sorting let currentResultsData = null; +// Pagination state +let currentPage = 1; +let itemsPerPage = 5000; // 5k items per page for good performance +let totalPages = 1; + // Initialize the application document.addEventListener('DOMContentLoaded', function() { // Get DOM elements after DOM is loaded @@ -39,7 +44,7 @@ function initializeEventListeners() { // Form submission searchForm.addEventListener('submit', async (e) => { e.preventDefault(); - await performSearch(); + await performSearch(true); // Reset to page 1 on new search }); // Clear button @@ -167,6 +172,10 @@ function clearAllFields() { // Reset slot filter document.getElementById('slotFilter').value = ''; + // Reset pagination + currentPage = 1; + totalPages = 1; + // Reset results and clear stored data currentResultsData = null; searchResults.innerHTML = '
Enter search criteria above and click "Search Items" to find inventory items.
'; @@ -196,7 +205,12 @@ function handleSlotFilterChange() { /** * Perform the search based on form inputs */ -async function performSearch() { +async function performSearch(resetPage = false) { + // Reset to page 1 if this is a new search (not pagination) + if (resetPage) { + currentPage = 1; + } + searchResults.innerHTML = '
🔍 Searching inventory...
'; try { @@ -214,11 +228,13 @@ async function performSearch() { // Store results for client-side re-sorting currentResultsData = data; + // Update pagination state + updatePaginationState(data); + // Apply client-side slot filtering applySlotFilter(data); - // Sort the results client-side before displaying - sortResults(data); + // Display results (already sorted by server) displayResults(data); } catch (error) { @@ -280,6 +296,8 @@ function buildSearchParameters() { addParam(params, 'max_damage_rating', 'searchMaxDamageRating'); addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost'); addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost'); + addParam(params, 'min_vitality_rating', 'searchMinVitalityRating'); + addParam(params, 'min_damage_resist_rating', 'searchMinDamageResistRating'); // Requirements parameters addParam(params, 'min_level', 'searchMinLevel'); @@ -308,8 +326,19 @@ function buildSearchParameters() { params.append('legendary_cantrips', allSpells.join(',')); } - // Pagination only - sorting will be done client-side - params.append('limit', '1000'); // Show all items on one page + // Equipment slot filters + const selectedSlots = getSelectedSlots(); + if (selectedSlots.length > 0) { + params.append('slot_names', selectedSlots.join(',')); + } + + // Pagination parameters + params.append('page', currentPage); + params.append('limit', itemsPerPage); + + // Sorting parameters + params.append('sort_by', currentSort.field); + params.append('sort_dir', currentSort.direction); return params; } @@ -357,6 +386,22 @@ function getSelectedProtections() { return selectedProtections; } +/** + * Get selected equipment slots from checkboxes + */ +function getSelectedSlots() { + const selectedSlots = []; + // Get armor slots + document.querySelectorAll('#armor-slots input[type="checkbox"]:checked').forEach(cb => { + selectedSlots.push(cb.value); + }); + // Get jewelry slots + document.querySelectorAll('#jewelry-slots input[type="checkbox"]:checked').forEach(cb => { + selectedSlots.push(cb.value); + }); + return selectedSlots; +} + /** * Display search results in the UI */ @@ -383,12 +428,16 @@ function displayResults(data) { Character${getSortIcon('character_name')} Item Name${getSortIcon('name')} Type${getSortIcon('item_type_name')} - Slot${getSortIcon('slot_name')} - Coverage${getSortIcon('coverage')} - Armor${getSortIcon('armor_level')} - Spells/Cantrips${getSortIcon('spell_names')} + Slot${getSortIcon('slot_name')} + Coverage${getSortIcon('coverage')} + Armor${getSortIcon('armor')} + Set${getSortIcon('item_set')} + Spells/Cantrips${getSortIcon('spell_names')} Crit Dmg${getSortIcon('crit_damage_rating')} Dmg Rating${getSortIcon('damage_rating')} + Heal Boost${getSortIcon('heal_boost_rating')} + Vitality${getSortIcon('vitality_rating')} + Dmg Resist${getSortIcon('damage_resist_rating')} Last Updated${getSortIcon('last_updated')} @@ -399,14 +448,19 @@ function displayResults(data) { const armor = item.armor_level > 0 ? item.armor_level : '-'; const critDmg = item.crit_damage_rating > 0 ? item.crit_damage_rating : '-'; const dmgRating = item.damage_rating > 0 ? item.damage_rating : '-'; + const healBoostRating = item.heal_boost_rating > 0 ? item.heal_boost_rating : '-'; + const vitalityRating = item.vitality_rating > 0 ? item.vitality_rating : '-'; + const damageResistRating = item.damage_resist_rating > 0 ? item.damage_resist_rating : '-'; const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory'; const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory'; // Use the slot_name provided by the API instead of incorrect mapping - const slot = item.slot_name || 'Unknown'; + // Replace commas with line breaks for better display + const slot = item.slot_name ? item.slot_name.replace(/,\s*/g, '
') : 'Unknown'; // Coverage placeholder - will need to be added to backend later - const coverage = item.coverage || '-'; + // Replace commas with line breaks for better display + const coverage = item.coverage ? item.coverage.replace(/,\s*/g, '
') : '-'; // Format last updated timestamp const lastUpdated = item.last_updated ? @@ -444,17 +498,30 @@ function displayResults(data) { // Get item type for display const itemType = item.item_type_name || '-'; + // Format equipment set name + let setDisplay = '-'; + if (item.item_set) { + // Remove redundant "Set" prefix if present + setDisplay = item.item_set.replace(/^Set\s+/i, ''); + // Also handle if it ends with " Set" + setDisplay = setDisplay.replace(/\s+Set$/i, ''); + } + html += ` ${item.character_name} ${displayName} ${itemType} - ${slot} - ${coverage} + ${slot} + ${coverage} ${armor} - ${spellsDisplay} + ${setDisplay} + ${spellsDisplay} ${critDmg} ${dmgRating} + ${healBoostRating} + ${vitalityRating} + ${damageResistRating} ${lastUpdated} `; @@ -465,11 +532,18 @@ function displayResults(data) { `; - // Add pagination info if needed - if (data.total_pages > 1) { + // Add pagination controls if needed + if (totalPages > 1) { + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + html += ` -
- Showing page ${data.page} of ${data.total_pages} pages +
+ + + Page ${currentPage} of ${totalPages} (${data.total_count} total items) + +
`; } @@ -566,20 +640,9 @@ function handleSort(field) { currentSort.direction = 'asc'; } - // Re-display current results with new sorting (no new search needed) - if (currentResultsData) { - // Reset items to original unfiltered data - const originalData = JSON.parse(JSON.stringify(currentResultsData)); - - // Apply slot filtering first - applySlotFilter(originalData); - - // Then apply sorting - sortResults(originalData); - - // Display results - displayResults(originalData); - } + // Reset to page 1 and perform new search with updated sort + currentPage = 1; + performSearch(); } /** @@ -722,3 +785,41 @@ function displaySetAnalysisResults(data) { setAnalysisResults.innerHTML = html; } + +/** + * Update pagination state from API response + */ +function updatePaginationState(data) { + totalPages = data.total_pages || 1; + // Current page is already tracked in currentPage +} + +/** + * Go to a specific page + */ +function goToPage(page) { + if (page < 1 || page > totalPages || page === currentPage) { + return; + } + + currentPage = page; + performSearch(); +} + +/** + * Go to next page + */ +function nextPage() { + if (currentPage < totalPages) { + goToPage(currentPage + 1); + } +} + +/** + * Go to previous page + */ +function previousPage() { + if (currentPage > 1) { + goToPage(currentPage - 1); + } +} diff --git a/static/script.js b/static/script.js index ad58310e..b37fbecf 100644 --- a/static/script.js +++ b/static/script.js @@ -1989,4 +1989,12 @@ function openQuestStatus() { window.open('/quest-status.html', '_blank'); } +/** + * Opens the Player Dashboard interface in a new browser tab. + */ +function openPlayerDashboard() { + // Open the Player Dashboard page in a new tab + window.open('/player-dashboard.html', '_blank'); +} + diff --git a/static/style.css b/static/style.css index b710bd52..1ef49873 100644 --- a/static/style.css +++ b/static/style.css @@ -1460,6 +1460,34 @@ body.noselect, body.noselect * { margin: -2px -4px; } +.player-dashboard-link { + margin: 0 0 12px; + padding: 8px 12px; + background: var(--card); + border: 1px solid #88f; + border-radius: 4px; + text-align: center; +} + +.player-dashboard-link a { + color: #88f; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + display: block; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; +} + +.player-dashboard-link a:hover { + color: #fff; + background: rgba(136, 136, 255, 0.1); + border-radius: 2px; + padding: 2px 4px; + margin: -2px -4px; +} + /* Sortable column styles for inventory tables */ .sortable { cursor: pointer;