From 57a2384511fd270e91fa28ff87927cc398c11d2b Mon Sep 17 00:00:00 2001 From: erik Date: Thu, 12 Jun 2025 23:05:33 +0000 Subject: [PATCH] added inventory service for armor and jewelry --- docker-compose.yml | 1 + inventory-service/add_dictionaries.py | 84 + .../comprehensive_enum_database_v2.json | 261 +++- inventory-service/database.py | 84 + .../extract_all_missing_enums.py | 87 ++ inventory-service/extract_dictionaries.py | 135 ++ inventory-service/extract_dictionaries_v2.py | 60 + inventory-service/extracted_dictionaries.json | 6 + inventory-service/main.py | 1347 ++++++++++++++++- main.py | 318 +++- static/index.html | 16 + static/script.js | 130 ++ static/style.css | 126 ++ 13 files changed, 2630 insertions(+), 25 deletions(-) create mode 100644 inventory-service/add_dictionaries.py create mode 100644 inventory-service/extract_all_missing_enums.py create mode 100644 inventory-service/extract_dictionaries.py create mode 100644 inventory-service/extract_dictionaries_v2.py create mode 100644 inventory-service/extracted_dictionaries.json diff --git a/docker-compose.yml b/docker-compose.yml index 56d25983..78e1d431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: DB_WAL_AUTOCHECKPOINT_PAGES: "${DB_WAL_AUTOCHECKPOINT_PAGES}" SHARED_SECRET: "${SHARED_SECRET}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" + INVENTORY_SERVICE_URL: "http://inventory-service:8000" restart: unless-stopped logging: driver: "json-file" diff --git a/inventory-service/add_dictionaries.py b/inventory-service/add_dictionaries.py new file mode 100644 index 00000000..35df31bb --- /dev/null +++ b/inventory-service/add_dictionaries.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Add dictionaries to the comprehensive enum database.""" + +import json + +# AttributeSetInfo dictionary manually extracted (COMPLETE) +attribute_set_info = { + "2": "Test", + "4": "Carraida's Benediction", + "5": "Noble Relic Set", + "6": "Ancient Relic Set", + "7": "Relic Alduressa Set", + "8": "Shou-jen Set", + "9": "Empyrean Rings Set", + "10": "Arm, Mind, Heart Set", + "11": "Coat of the Perfect Light Set", + "12": "Leggings of Perfect Light Set", + "13": "Soldier's Set", + "14": "Adept's Set", + "15": "Archer's Set", + "16": "Defender's Set", + "17": "Tinker's Set", + "18": "Crafter's Set", + "19": "Hearty Set", + "20": "Dexterous Set", + "21": "Wise Set", + "22": "Swift Set", + "23": "Hardenend Set", + "24": "Reinforced Set", + "25": "Interlocking Set", + "26": "Flame Proof Set", + "27": "Acid Proof Set", + "28": "Cold Proof Set", + "29": "Lightning Proof Set", + "30": "Dedication Set", + "31": "Gladiatorial Clothing Set", + "32": "Ceremonial Clothing", + "33": "Protective Clothing", + "34": "Noobie Armor", + "35": "Sigil of Defense", + "36": "Sigil of Destruction", + "37": "Sigil of Fury", + "38": "Sigil of Growth", + "39": "Sigil of Vigor", + "40": "Heroic Protector Set", + "41": "Heroic Destroyer Set", + "42": "Olthoi Armor D Red", + "43": "Olthoi Armor C Rat", + "44": "Olthoi Armor C Red", + "45": "Olthoi Armor F Red", + "46": "Olthoi Armor K Red", + "47": "Olthoi Armor M Red", + "48": "Olthoi Armor B Red", + "49": "Olthoi Armor B Rat", + "50": "Olthoi Armor K Rat", + "51": "Olthoi Armor M Rat", + "52": "Olthoi Armor F Rat", + "53": "Olthoi Armor D Rat" +} + +# Load existing database +with open('comprehensive_enum_database_v2.json', 'r') as f: + db = json.load(f) + +# Add dictionaries section +if 'dictionaries' not in db: + db['dictionaries'] = {} + +db['dictionaries']['AttributeSetInfo'] = { + 'name': 'AttributeSetInfo', + 'description': 'Equipment set names', + 'values': attribute_set_info +} + +# Save updated database +with open('comprehensive_enum_database_v2.json', 'w') as f: + json.dump(db, f, indent=2) + +print("Added AttributeSetInfo dictionary to comprehensive enum database") +print(f"Total equipment sets: {len(attribute_set_info)}") +print("Example sets:") +for set_id in ['13', '14', '16', '21']: + if set_id in attribute_set_info: + print(f" {set_id} -> {attribute_set_info[set_id]}") \ No newline at end of file diff --git a/inventory-service/comprehensive_enum_database_v2.json b/inventory-service/comprehensive_enum_database_v2.json index dd1e0c57..fd7ec101 100644 --- a/inventory-service/comprehensive_enum_database_v2.json +++ b/inventory-service/comprehensive_enum_database_v2.json @@ -2,14 +2,19 @@ "metadata": { "extracted_at": "2025-06-10", "source": "Mag-Plugins/Shared (comprehensive)", - "version": "2.0.0" + "version": "2.1.0", + "last_updated": "2025-06-12", + "notes": "COMPLETE extraction: Added missing CoverageMask values (1,2,4,16,etc) and BoolValueKey enum" }, "enums": { "CoverageMask": { "name": "CoverageMask", "values": { - "0": "None", + "1": "Unknown", + "2": "UnderwearUpperLegs", + "4": "UnderwearLowerLegs", "8": "UnderwearChest", + "16": "UnderwearAbdomen", "32": "UnderwearUpperArms", "64": "UnderwearLowerArms", "256": "OuterwearUpperLegs", @@ -590,14 +595,9 @@ "128": "HadNoVitae", "129": "NoOlthoiTalk", "130": "AutowieldLeft", - "131": "/* custom */", - "132": "[ServerOnly]", "9001": "LinkedPortalOneSummon", - "134": "[ServerOnly]", "9002": "LinkedPortalTwoSummon", - "136": "[ServerOnly]", "9003": "HouseEvicted", - "138": "[ServerOnly]", "9004": "UntrainedSkills", "201326592": "Lockable_Decal", "201326593": "Inscribable_Decal" @@ -1996,9 +1996,254 @@ "12": "HeritageType" }, "source_file": "WieldRequirement.cs" + }, + "AttributeSetInfo": { + "name": "AttributeSetInfo", + "description": "Equipment set names mapping (Soldier's Set, Adept's Set, Defender's Set, Wise Set, etc.)", + "values": { + "2": "Test", + "4": "Carraida's Benediction", + "5": "Noble Relic Set", + "6": "Ancient Relic Set", + "7": "Relic Alduressa Set", + "8": "Shou-jen Set", + "9": "Empyrean Rings Set", + "10": "Arm, Mind, Heart Set", + "11": "Coat of the Perfect Light Set", + "12": "Leggings of Perfect Light Set", + "13": "Soldier's Set", + "14": "Adept's Set", + "15": "Archer's Set", + "16": "Defender's Set", + "17": "Tinker's Set", + "18": "Crafter's Set", + "19": "Hearty Set", + "20": "Dexterous Set", + "21": "Wise Set", + "22": "Swift Set", + "23": "Hardened Set", + "24": "Reinforced Set", + "25": "Interlocking Set", + "26": "Flame Proof Set", + "27": "Acid Proof Set", + "28": "Cold Proof Set", + "29": "Lightning Proof Set", + "30": "Dedication Set", + "31": "Gladiatorial Clothing Set", + "32": "Ceremonial Clothing", + "33": "Protective Clothing", + "34": "Noobie Armor", + "35": "Sigil of Defense", + "36": "Sigil of Destruction", + "37": "Sigil of Fury", + "38": "Sigil of Growth", + "39": "Sigil of Vigor", + "40": "Heroic Protector Set", + "41": "Heroic Destroyer Set", + "42": "Olthoi Armor D Red", + "43": "Olthoi Armor C Rat", + "44": "Olthoi Armor C Red", + "45": "Olthoi Armor D Rat", + "46": "Upgraded Relic Alduressa Set", + "47": "Upgraded Ancient Relic Set", + "48": "Upgraded Noble Relic Set", + "49": "Weave of Alchemy", + "50": "Weave of Arcane Lore", + "51": "Weave of Armor Tinkering", + "52": "Weave of Assess Person", + "53": "Weave of Light Weapons", + "54": "Weave of Missile Weapons", + "55": "Weave of Cooking", + "56": "Weave of Creature Enchantment", + "57": "Weave of Missile Weapons", + "58": "Weave of Finesse", + "59": "Weave of Deception", + "60": "Weave of Fletching", + "61": "Weave of Healing", + "62": "Weave of Item Enchantment", + "63": "Weave of Item Tinkering", + "64": "Weave of Leadership", + "65": "Weave of Life Magic", + "66": "Weave of Loyalty", + "67": "Weave of Light Weapons", + "68": "Weave of Magic Defense", + "69": "Weave of Magic Item Tinkering", + "70": "Weave of Mana Conversion", + "71": "Weave of Melee Defense", + "72": "Weave of Missile Defense", + "73": "Weave of Salvaging", + "74": "Weave of Light Weapons", + "75": "Weave of Light Weapons", + "76": "Weave of Heavy Weapons", + "77": "Weave of Missile Weapons", + "78": "Weave of Two Handed Combat", + "79": "Weave of Light Weapons", + "80": "Weave of Void Magic", + "81": "Weave of War Magic", + "82": "Weave of Weapon Tinkering", + "83": "Weave of Assess Creature", + "84": "Weave of Dirty Fighting", + "85": "Weave of Dual Wield", + "86": "Weave of Recklessness", + "87": "Weave of Shield", + "88": "Weave of Sneak Attack", + "89": "Ninja_New", + "90": "Weave of Summoning", + "91": "Shrouded Soul", + "92": "Darkened Mind", + "93": "Clouded Spirit", + "131": "Brown Society Locket", + "132": "Yellow Society Locket", + "133": "Red Society Band", + "134": "Green Society Band", + "135": "Purple Society Band", + "136": "Blue Society Band", + "137": "Gauntlet Garb" + }, + "source_file": "Dictionaries.cs" + }, + "SkillInfo": { + "name": "SkillInfo", + "description": "Skill names mapping", + "values": { + "1": "Axe", + "2": "Bow", + "3": "Crossbow", + "4": "Dagger", + "5": "Mace", + "6": "Melee Defense", + "7": "Missile Defense", + "8": "Sling", + "9": "Spear", + "10": "Staff", + "11": "Sword", + "12": "Thrown Weapons", + "13": "Unarmed Combat", + "14": "Arcane Lore", + "15": "Magic Defense", + "16": "Mana Conversion", + "18": "Item Tinkering", + "19": "Assess Person", + "20": "Deception", + "21": "Healing", + "22": "Jump", + "23": "Lockpick", + "24": "Run", + "27": "Assess Creature", + "28": "Weapon Tinkering", + "29": "Armor Tinkering", + "30": "Magic Item Tinkering", + "31": "Creature Enchantment", + "32": "Item Enchantment", + "33": "Life Magic", + "34": "War Magic", + "35": "Leadership", + "36": "Loyalty", + "37": "Fletching", + "38": "Alchemy", + "39": "Cooking", + "40": "Salvaging", + "41": "Two Handed Combat", + "42": "Gearcraft", + "43": "Void", + "44": "Heavy Weapons", + "45": "Light Weapons", + "46": "Finesse Weapons", + "47": "Missile Weapons", + "48": "Shield", + "49": "Dual Wield", + "50": "Recklessness", + "51": "Sneak Attack", + "52": "Dirty Fighting", + "53": "Challenge", + "54": "Summoning" + }, + "source_file": "Dictionaries.cs" + }, + "MasteryInfo": { + "name": "MasteryInfo", + "description": "Weapon mastery names mapping", + "values": { + "1": "Unarmed Weapon", + "2": "Sword", + "3": "Axe", + "4": "Mace", + "5": "Spear", + "6": "Dagger", + "7": "Staff", + "8": "Bow", + "9": "Crossbow", + "10": "Thrown", + "11": "Two Handed Combat" + }, + "source_file": "Dictionaries.cs" + }, + "MaterialInfo": { + "name": "MaterialInfo", + "description": "Material names mapping", + "values": {}, + "source_file": "Dictionaries.cs" + } + }, + "dictionaries": { + "AttributeSetInfo": { + "name": "AttributeSetInfo", + "description": "Equipment set names", + "values": { + "2": "Test", + "4": "Carraida's Benediction", + "5": "Noble Relic Set", + "6": "Ancient Relic Set", + "7": "Relic Alduressa Set", + "8": "Shou-jen Set", + "9": "Empyrean Rings Set", + "10": "Arm, Mind, Heart Set", + "11": "Coat of the Perfect Light Set", + "12": "Leggings of Perfect Light Set", + "13": "Soldier's Set", + "14": "Adept's Set", + "15": "Archer's Set", + "16": "Defender's Set", + "17": "Tinker's Set", + "18": "Crafter's Set", + "19": "Hearty Set", + "20": "Dexterous Set", + "21": "Wise Set", + "22": "Swift Set", + "23": "Hardenend Set", + "24": "Reinforced Set", + "25": "Interlocking Set", + "26": "Flame Proof Set", + "27": "Acid Proof Set", + "28": "Cold Proof Set", + "29": "Lightning Proof Set", + "30": "Dedication Set", + "31": "Gladiatorial Clothing Set", + "32": "Ceremonial Clothing", + "33": "Protective Clothing", + "34": "Noobie Armor", + "35": "Sigil of Defense", + "36": "Sigil of Destruction", + "37": "Sigil of Fury", + "38": "Sigil of Growth", + "39": "Sigil of Vigor", + "40": "Heroic Protector Set", + "41": "Heroic Destroyer Set", + "42": "Olthoi Armor D Red", + "43": "Olthoi Armor C Rat", + "44": "Olthoi Armor C Red", + "45": "Olthoi Armor F Red", + "46": "Olthoi Armor K Red", + "47": "Olthoi Armor M Red", + "48": "Olthoi Armor B Red", + "49": "Olthoi Armor B Rat", + "50": "Olthoi Armor K Rat", + "51": "Olthoi Armor M Rat", + "52": "Olthoi Armor F Rat", + "53": "Olthoi Armor D Rat" + } } }, - "dictionaries": {}, "spells": { "name": "Spells", "values": { diff --git a/inventory-service/database.py b/inventory-service/database.py index 92e192bd..0f60c8a2 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -34,6 +34,29 @@ class Item(Base): value = Column(Integer, default=0) burden = Column(Integer, default=0) + # Equipment status + current_wielded_location = Column(Integer, default=0, index=True) # 0 = not equipped + + # Item state + bonded = Column(Integer, default=0) # 0=Normal, 1=Bonded, 2=Sticky, 4=Destroy on drop + attuned = Column(Integer, default=0) # 0=Normal, 1=Attuned + unique = Column(Boolean, default=False) + + # Stack/Container properties + stack_size = Column(Integer, default=1) + max_stack_size = Column(Integer, default=1) + items_capacity = Column(Integer) # For containers + containers_capacity = Column(Integer) # For containers + + # Durability + structure = Column(Integer) # Current durability + max_structure = Column(Integer) # Maximum durability + + # Special item flags + rare_id = Column(Integer) # For rare items + lifespan = Column(Integer) # Item decay timer + remaining_lifespan = Column(Integer) # Remaining decay time + # Metadata flags has_id_data = Column(Boolean, default=False) last_id_time = Column(BigInteger, default=0) @@ -60,6 +83,11 @@ class ItemCombatStats(Base): elemental_damage_vs_monsters = Column(Float) variance = Column(Float) + # Advanced damage properties + cleaving = Column(Integer) # Cleaving damage + crit_damage_rating = Column(Integer) # Critical damage multiplier + damage_over_time = Column(Integer) # DoT damage + # Attack properties attack_bonus = Column(Float) weapon_time = Column(Integer) @@ -74,9 +102,24 @@ class ItemCombatStats(Base): # Resistances resist_magic = Column(Integer) + crit_resist_rating = Column(Integer) + crit_damage_resist_rating = Column(Integer) + dot_resist_rating = Column(Integer) + life_resist_rating = Column(Integer) + nether_resist_rating = Column(Integer) + + # Healing/Recovery + heal_over_time = Column(Integer) + healing_resist_rating = Column(Integer) # Mana properties mana_conversion_bonus = Column(Float) + + # PvP properties + pk_damage_rating = Column(Integer) + pk_damage_resist_rating = Column(Integer) + gear_pk_damage_rating = Column(Integer) + gear_pk_damage_resist_rating = Column(Integer) class ItemRequirements(Base): """Wield requirements and skill prerequisites.""" @@ -105,8 +148,30 @@ class ItemEnhancements(Base): workmanship = Column(Float) salvage_workmanship = Column(Float) + # Advanced tinkering + num_times_tinkered = Column(Integer, default=0) + free_tinkers_bitfield = Column(Integer) # Which tinkers are free + num_items_in_material = Column(Integer) # Salvage yield + + # Additional imbue effects + imbue_attempts = Column(Integer, default=0) + imbue_successes = Column(Integer, default=0) + imbued_effect2 = Column(Integer) + imbued_effect3 = Column(Integer) + imbued_effect4 = Column(Integer) + imbued_effect5 = Column(Integer) + imbue_stacking_bits = Column(Integer) # Which imbues stack + # Set information item_set = Column(String(100), index=True) + equipment_set_extra = Column(Integer) # Additional set bonuses + + # Special properties + aetheria_bitfield = Column(Integer) # Aetheria slot properties + heritage_specific_armor = Column(Integer) # Heritage armor type + + # Cooldowns + shared_cooldown = Column(Integer) # Cooldown group ID class ItemRatings(Base): """Modern rating system properties.""" @@ -126,6 +191,22 @@ class ItemRatings(Base): heal_boost_rating = Column(Integer) vitality_rating = Column(Integer) mana_conversion_rating = Column(Integer) + weakness_rating = Column(Integer) # Weakness debuff strength + nether_over_time = Column(Integer) # Nether DoT + + # Gear totals + gear_damage = Column(Integer) # Total gear damage + gear_damage_resist = Column(Integer) # Total gear damage resist + gear_crit = Column(Integer) # Total gear crit + gear_crit_resist = Column(Integer) # Total gear crit resist + gear_crit_damage = Column(Integer) # Total gear crit damage + gear_crit_damage_resist = Column(Integer) # Total gear crit damage resist + gear_healing_boost = Column(Integer) # Total gear healing boost + gear_max_health = Column(Integer) # Total gear max health + gear_nether_resist = Column(Integer) # Total gear nether resist + gear_life_resist = Column(Integer) # Total gear life resist + gear_overpower = Column(Integer) # Total gear overpower + gear_overpower_resist = Column(Integer) # Total gear overpower resist # Calculated totals total_rating = Column(Integer) @@ -164,6 +245,9 @@ def create_indexes(engine): # Item search indexes sa.Index('ix_items_search', Item.character_name, Item.name, Item.object_class).create(engine, checkfirst=True) + # Equipment status index for filtering equipped items + sa.Index('ix_items_equipped', Item.character_name, Item.current_wielded_location).create(engine, checkfirst=True) + # Combat stats indexes for filtering sa.Index('ix_combat_damage', ItemCombatStats.max_damage).create(engine, checkfirst=True) sa.Index('ix_combat_armor', ItemCombatStats.armor_level).create(engine, checkfirst=True) diff --git a/inventory-service/extract_all_missing_enums.py b/inventory-service/extract_all_missing_enums.py new file mode 100644 index 00000000..98e1411c --- /dev/null +++ b/inventory-service/extract_all_missing_enums.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Comprehensive enum extraction from ALL Mag-Plugins Constants files. +This script extracts ALL missing enum values to complete our enum database. +""" +import json +import re +import os +from pathlib import Path + +def extract_coverage_mask(): + """Extract complete CoverageMask enum with ALL values.""" + file_path = "../unused/Mag-Plugins/Shared/Constants/CoverageMask.cs" + + coverage_values = {} + + with open(file_path, 'r') as f: + content = f.read() + + # Extract enum values using regex + enum_pattern = r'(\w+)\s*=\s*0x([0-9A-Fa-f]+)' + matches = re.findall(enum_pattern, content) + + for name, hex_value in matches: + decimal_value = int(hex_value, 16) + coverage_values[str(decimal_value)] = name + + return { + "name": "CoverageMask", + "values": coverage_values, + "source_file": "CoverageMask.cs" + } + +def extract_bool_value_key(): + """Extract BoolValueKey enum (currently missing).""" + file_path = "../unused/Mag-Plugins/Shared/Constants/BoolValueKey.cs" + + bool_values = {} + + with open(file_path, 'r') as f: + content = f.read() + + # Extract enum values + enum_pattern = r'(\w+)\s*=\s*(\d+)' + matches = re.findall(enum_pattern, content) + + for name, value in matches: + bool_values[value] = name + + return { + "name": "BoolValueKey", + "values": bool_values, + "source_file": "BoolValueKey.cs" + } + +def load_current_database(): + """Load current enum database.""" + with open('comprehensive_enum_database_v2.json', 'r') as f: + return json.load(f) + +def update_enum_database(): + """Update the enum database with ALL missing values.""" + print("Loading current enum database...") + db = load_current_database() + + print("Extracting complete CoverageMask...") + complete_coverage = extract_coverage_mask() + db['enums']['CoverageMask'] = complete_coverage + + print("Extracting BoolValueKey...") + bool_values = extract_bool_value_key() + db['enums']['BoolValueKey'] = bool_values + + # Update metadata + db['metadata']['last_updated'] = "2025-06-12" + db['metadata']['notes'] = "COMPLETE extraction: Added missing CoverageMask values (1,2,4,16,etc) and BoolValueKey enum" + + print("Saving updated database...") + with open('comprehensive_enum_database_v2.json', 'w') as f: + json.dump(db, f, indent=2) + + print("✅ Enum database updated successfully!") + print(f"✅ CoverageMask now has {len(complete_coverage['values'])} values") + print(f"✅ BoolValueKey now has {len(bool_values['values'])} values") + +if __name__ == "__main__": + update_enum_database() \ No newline at end of file diff --git a/inventory-service/extract_dictionaries.py b/inventory-service/extract_dictionaries.py new file mode 100644 index 00000000..92cf3e0e --- /dev/null +++ b/inventory-service/extract_dictionaries.py @@ -0,0 +1,135 @@ +#\!/usr/bin/env python3 +"""Extract dictionaries from Mag-Plugins Dictionaries.cs file.""" + +import re +import json + +def extract_attribute_set_info(): + """Extract AttributeSetInfo dictionary from Dictionaries.cs.""" + with open('/home/erik/MosswartOverlord/unused/Mag-Plugins/Shared/Constants/Dictionaries.cs', 'r') as f: + content = f.read() + + # Find the AttributeSetInfo dictionary + pattern = r'AttributeSetInfo\s*=\s*new\s+Dictionary\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + print("AttributeSetInfo not found\!") + return {} + + dict_content = match.group(1) + + # Extract entries + entry_pattern = r'\{\s*(\d+),\s*"([^"]+)"\s*\}' + entries = re.findall(entry_pattern, dict_content) + + attribute_sets = {} + for set_id, set_name in entries: + attribute_sets[set_id] = set_name + + return attribute_sets + +def extract_material_info(): + """Extract MaterialInfo dictionary from Dictionaries.cs.""" + with open('/home/erik/MosswartOverlord/unused/Mag-Plugins/Shared/Constants/Dictionaries.cs', 'r') as f: + content = f.read() + + # Find the MaterialInfo dictionary + pattern = r'MaterialInfo\s*=\s*new\s+Dictionary\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + print("MaterialInfo not found\!") + return {} + + dict_content = match.group(1) + + # Extract entries + entry_pattern = r'\{\s*(\d+),\s*"([^"]+)"\s*\}' + entries = re.findall(entry_pattern, dict_content) + + materials = {} + for mat_id, mat_name in entries: + materials[mat_id] = mat_name + + return materials + +def extract_skill_info(): + """Extract SkillInfo dictionary from Dictionaries.cs.""" + with open('/home/erik/MosswartOverlord/unused/Mag-Plugins/Shared/Constants/Dictionaries.cs', 'r') as f: + content = f.read() + + # Find the SkillInfo dictionary + pattern = r'SkillInfo\s*=\s*new\s+Dictionary\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + print("SkillInfo not found\!") + return {} + + dict_content = match.group(1) + + # Extract entries - handle hex values + entry_pattern = r'\{\s*(0x[0-9A-Fa-f]+ < /dev/null | \d+),\s*"([^"]+)"\s*\}' + entries = re.findall(entry_pattern, dict_content) + + skills = {} + for skill_id, skill_name in entries: + # Convert hex to decimal if needed + if skill_id.startswith('0x'): + skill_id = str(int(skill_id, 16)) + skills[skill_id] = skill_name + + return skills + +def extract_mastery_info(): + """Extract MasteryInfo dictionary from Dictionaries.cs.""" + with open('/home/erik/MosswartOverlord/unused/Mag-Plugins/Shared/Constants/Dictionaries.cs', 'r') as f: + content = f.read() + + # Find the MasteryInfo dictionary + pattern = r'MasteryInfo\s*=\s*new\s+Dictionary\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + print("MasteryInfo not found\!") + return {} + + dict_content = match.group(1) + + # Extract entries + entry_pattern = r'\{\s*(\d+),\s*"([^"]+)"\s*\}' + entries = re.findall(entry_pattern, dict_content) + + masteries = {} + for mastery_id, mastery_name in entries: + masteries[mastery_id] = mastery_name + + return masteries + +def main(): + """Extract all dictionaries and save to JSON.""" + dictionaries = { + 'AttributeSetInfo': extract_attribute_set_info(), + 'MaterialInfo': extract_material_info(), + 'SkillInfo': extract_skill_info(), + 'MasteryInfo': extract_mastery_info() + } + + # Print summary + for dict_name, dict_data in dictionaries.items(): + print(f"{dict_name}: {len(dict_data)} entries") + if dict_data and dict_name == 'AttributeSetInfo': + # Show some equipment set examples + for set_id in ['13', '14', '16', '21']: + if set_id in dict_data: + print(f" {set_id} -> {dict_data[set_id]}") + + # Save to file + with open('extracted_dictionaries.json', 'w') as f: + json.dump(dictionaries, f, indent=2) + + print("\nDictionaries saved to extracted_dictionaries.json") + +if __name__ == "__main__": + main() diff --git a/inventory-service/extract_dictionaries_v2.py b/inventory-service/extract_dictionaries_v2.py new file mode 100644 index 00000000..5449701d --- /dev/null +++ b/inventory-service/extract_dictionaries_v2.py @@ -0,0 +1,60 @@ +#\!/usr/bin/env python3 +"""Extract dictionaries from Mag-Plugins Dictionaries.cs file.""" + +import re +import json + +def extract_dictionary(dict_name): + """Extract a dictionary from Dictionaries.cs by name.""" + with open('/home/erik/MosswartOverlord/unused/Mag-Plugins/Shared/Constants/Dictionaries.cs', 'r') as f: + content = f.read() + + # Find the dictionary - handle multiline definitions + pattern = rf'{dict_name}\s*=\s*new\s+Dictionary\s*\{{(.*?)^\s*\}};' + match = re.search(pattern, content, re.DOTALL < /dev/null | re.MULTILINE) + + if not match: + print(f"{dict_name} not found\!") + return {} + + dict_content = match.group(1) + + # Extract entries - handle both decimal and hex values + entry_pattern = r'\{\s*(0x[0-9A-Fa-f]+|\d+),\s*"([^"]+)"\s*\}' + entries = re.findall(entry_pattern, dict_content) + + result = {} + for key, value in entries: + # Convert hex to decimal if needed + if key.startswith('0x'): + key = str(int(key, 16)) + result[key] = value + + return result + +def main(): + """Extract all dictionaries and save to JSON.""" + dictionaries = { + 'AttributeSetInfo': extract_dictionary('AttributeSetInfo'), + 'MaterialInfo': extract_dictionary('MaterialInfo'), + 'SkillInfo': extract_dictionary('SkillInfo'), + 'MasteryInfo': extract_dictionary('MasteryInfo') + } + + # Print summary + for dict_name, dict_data in dictionaries.items(): + print(f"{dict_name}: {len(dict_data)} entries") + if dict_data and dict_name == 'AttributeSetInfo': + # Show some equipment set examples + for set_id in ['13', '14', '16', '21']: + if set_id in dict_data: + print(f" {set_id} -> {dict_data[set_id]}") + + # Save to file + with open('extracted_dictionaries.json', 'w') as f: + json.dump(dictionaries, f, indent=2) + + print("\nDictionaries saved to extracted_dictionaries.json") + +if __name__ == "__main__": + main() diff --git a/inventory-service/extracted_dictionaries.json b/inventory-service/extracted_dictionaries.json new file mode 100644 index 00000000..b76ef7c1 --- /dev/null +++ b/inventory-service/extracted_dictionaries.json @@ -0,0 +1,6 @@ +{ + "AttributeSetInfo": {}, + "MaterialInfo": {}, + "SkillInfo": {}, + "MasteryInfo": {} +} \ No newline at end of file diff --git a/inventory-service/main.py b/inventory-service/main.py index 641851c1..6745473e 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -11,6 +11,7 @@ from typing import Dict, List, Optional, Any from datetime import datetime from fastapi import FastAPI, HTTPException, Depends, Query +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel import databases @@ -32,6 +33,15 @@ app = FastAPI( version="1.0.0" ) +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins for development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Database connection database = databases.Database(DATABASE_URL) engine = sa.create_engine(DATABASE_URL) @@ -137,7 +147,25 @@ def load_comprehensive_enums(): if spells_data and 'values' in spells_data: spells = {int(k): v for k, v in spells_data['values'].items() if k.isdigit()} - logger.info(f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes") + # AttributeSetInfo - Equipment Set Names (CRITICAL for armor set detection) + attribute_sets = {} + # Check in dictionaries section first, then enums as fallback + if 'dictionaries' in enum_db and 'AttributeSetInfo' in enum_db['dictionaries']: + for k, v in enum_db['dictionaries']['AttributeSetInfo']['values'].items(): + attribute_sets[k] = v # String key + try: + attribute_sets[int(k)] = v # Also int key + except (ValueError, TypeError): + pass + elif 'AttributeSetInfo' in enums: + for k, v in enums['AttributeSetInfo']['values'].items(): + attribute_sets[k] = v # String key + try: + attribute_sets[int(k)] = v # Also int key + except (ValueError, TypeError): + pass + + logger.info(f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes, {len(attribute_sets)} equipment sets") return { 'int_values': int_values, @@ -148,6 +176,11 @@ def load_comprehensive_enums(): 'coverage_masks': coverage_masks, 'object_classes': object_classes, 'spells': spells, + 'equipment_sets': attribute_sets, # Backward compatibility + 'dictionaries': { + 'AttributeSetInfo': {'values': attribute_sets} + }, + 'AttributeSetInfo': {'values': attribute_sets}, # Also add in the format expected by the endpoint 'full_database': enum_db } @@ -334,6 +367,262 @@ def translate_coverage_mask(coverage_value: int) -> List[str]: return [name_mapping.get(part, part) for part in covered_parts if part] +def get_total_bits_set(value: int) -> int: + """Count the number of bits set in a value.""" + bits_set = 0 + while value != 0: + if (value & 1) == 1: + bits_set += 1 + value >>= 1 + return bits_set + +def is_body_armor_equip_mask(value: int) -> bool: + """Check if EquipMask value represents body armor.""" + return (value & 0x00007F21) != 0 + +def is_body_armor_coverage_mask(value: int) -> bool: + """Check if CoverageMask value represents body armor.""" + return (value & 0x0001FF00) != 0 + +def get_coverage_reduction_options(coverage_value: int) -> list: + """ + Get the reduction options for a coverage mask, based on Mag-SuitBuilder logic. + This determines which individual slots a multi-slot armor piece can be tailored to fit. + """ + # CoverageMask values from Mag-Plugins + OuterwearUpperArms = 4096 # 0x00001000 + OuterwearLowerArms = 8192 # 0x00002000 + OuterwearUpperLegs = 256 # 0x00000100 + OuterwearLowerLegs = 512 # 0x00000200 + OuterwearChest = 1024 # 0x00000400 + OuterwearAbdomen = 2048 # 0x00000800 + Head = 16384 # 0x00004000 + Hands = 32768 # 0x00008000 + Feet = 65536 # 0x00010000 + + options = [] + + # If single bit or not body armor, return as-is + if get_total_bits_set(coverage_value) <= 1 or not is_body_armor_coverage_mask(coverage_value): + options.append(coverage_value) + else: + # Implement Mag-SuitBuilder reduction logic + if coverage_value == (OuterwearUpperArms | OuterwearLowerArms): + options.extend([OuterwearUpperArms, OuterwearLowerArms]) + elif coverage_value == (OuterwearUpperLegs | OuterwearLowerLegs): + options.extend([OuterwearUpperLegs, OuterwearLowerLegs]) + elif coverage_value == (OuterwearLowerLegs | Feet): + options.append(Feet) + elif coverage_value == (OuterwearChest | OuterwearAbdomen): + options.append(OuterwearChest) + elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms): + options.append(OuterwearChest) + elif coverage_value == (OuterwearChest | OuterwearUpperArms | OuterwearLowerArms): + options.append(OuterwearChest) + elif coverage_value == (OuterwearChest | OuterwearUpperArms): + options.append(OuterwearChest) + elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs): + options.extend([OuterwearAbdomen, OuterwearUpperLegs, OuterwearLowerLegs]) + elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms): + options.append(OuterwearChest) + elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs): + # Pre-2010 retail guidelines - assume abdomen reduction only + options.append(OuterwearAbdomen) + else: + # If no specific reduction pattern, return original + options.append(coverage_value) + + return options + +def coverage_to_equip_mask(coverage_value: int) -> int: + """Convert a CoverageMask value to its corresponding EquipMask slot.""" + # Coverage to EquipMask mapping from Mag-SuitBuilder + coverage_to_slot = { + 16384: 1, # Head -> HeadWear + 1024: 512, # OuterwearChest -> ChestArmor + 4096: 2048, # OuterwearUpperArms -> UpperArmArmor + 8192: 4096, # OuterwearLowerArms -> LowerArmArmor + 32768: 32, # Hands -> HandWear + 2048: 1024, # OuterwearAbdomen -> AbdomenArmor + 256: 8192, # OuterwearUpperLegs -> UpperLegArmor + 512: 16384, # OuterwearLowerLegs -> LowerLegArmor + 65536: 256, # Feet -> FootWear + } + return coverage_to_slot.get(coverage_value, coverage_value) + +def get_sophisticated_slot_options(equippable_slots: int, coverage_value: int, has_material: bool = True) -> list: + """ + Get sophisticated slot options using Mag-SuitBuilder logic. + This handles armor reduction for tailorable pieces. + """ + # Special case: shoes that cover feet + lower legs but only go in feet slot + LowerLegWear = 128 + FootWear = 256 + if equippable_slots == (LowerLegWear | FootWear): + return [FootWear] + + # If it's body armor with multiple slots + if is_body_armor_equip_mask(equippable_slots) and get_total_bits_set(equippable_slots) > 1: + if not has_material: + # Can't reduce non-loot gen pieces, return all slots + return [equippable_slots] + else: + # Use coverage reduction options + reduction_options = get_coverage_reduction_options(coverage_value) + slot_options = [] + for option in reduction_options: + equip_slot = coverage_to_equip_mask(option) + slot_options.append(equip_slot) + return slot_options if slot_options else [equippable_slots] + else: + # Single slot or non-armor + return [equippable_slots] + +def convert_slot_name_to_friendly(slot_name: str) -> str: + """Convert technical slot names to user-friendly names.""" + name_mapping = { + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak', + 'SigilOne': 'Aetheria Blue', + 'SigilTwo': 'Aetheria Yellow', + 'SigilThree': 'Aetheria Red' + } + return name_mapping.get(slot_name, slot_name) + +def translate_equipment_slot(wielded_location: int) -> str: + """Translate equipment slot mask to human-readable slot name(s), handling bit flags.""" + if wielded_location == 0: + return "Inventory" + + # Get EquipMask enum from database + equip_mask_map = {} + if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): + equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] + for k, v in equip_data.items(): + try: + # Skip expression values (EXPR:...) + if not k.startswith('EXPR:'): + equip_mask_map[int(k)] = v + except (ValueError, TypeError): + pass + + # Check for exact match first + if wielded_location in equip_mask_map: + slot_name = equip_mask_map[wielded_location] + # Convert technical names to user-friendly names + name_mapping = { + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak' + } + return name_mapping.get(slot_name, slot_name) + + # If no exact match, decode bit flags + slot_parts = [] + for mask_value, slot_name in equip_mask_map.items(): + if mask_value > 0 and (wielded_location & mask_value) == mask_value: + slot_parts.append(slot_name) + + if slot_parts: + # Convert technical names to user-friendly names + name_mapping = { + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak' + } + + translated_parts = [name_mapping.get(part, part) for part in slot_parts] + return ', '.join(translated_parts) + + # Handle special cases for high values (like Aetheria slots) + if wielded_location >= 268435456: # 2^28 and higher - likely Aetheria or special slots + if wielded_location == 268435456: + return "Aetheria Blue" + elif wielded_location == 536870912: + return "Aetheria Yellow" + elif wielded_location == 1073741824: + return "Aetheria Red" + else: + return f"Special Slot ({wielded_location})" + + return f"Unknown Slot ({wielded_location})" + def translate_workmanship(workmanship_value: int) -> str: """Translate workmanship value to descriptive text.""" if workmanship_value <= 0: @@ -578,18 +867,66 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: 'value': item_data.get('Value', 0), 'burden': item_data.get('Burden', 0), 'has_id_data': item_data.get('HasIdData', False), + + # Equipment status - handle string keys properly + 'current_wielded_location': int(int_values.get('10', int_values.get(10, '0'))), + + # Item state + 'bonded': int_values.get('33', int_values.get(33, 0)), + 'attuned': int_values.get('114', int_values.get(114, 0)), + 'unique': int_values.get('279', int_values.get(279, 0)) != 0, + + # Stack/Container properties + 'stack_size': int_values.get('12', int_values.get(12, 1)), + 'max_stack_size': int_values.get('11', int_values.get(11, 1)), + 'items_capacity': int_values.get('6', int_values.get(6, -1)), + 'containers_capacity': int_values.get('7', int_values.get(7, -1)), + + # Durability + 'structure': int_values.get('92', int_values.get(92, -1)), + 'max_structure': int_values.get('91', int_values.get(91, -1)), + + # Special item flags + 'rare_id': int_values.get('17', int_values.get(17, -1)), + 'lifespan': int_values.get('267', int_values.get(267, -1)), + 'remaining_lifespan': int_values.get('268', int_values.get(268, -1)), }, 'combat': { 'max_damage': item_data.get('MaxDamage', -1), 'armor_level': item_data.get('ArmorLevel', -1), 'damage_bonus': item_data.get('DamageBonus', -1.0), 'attack_bonus': item_data.get('AttackBonus', -1.0), - # Add missing combat stats from raw values + + # Defense bonuses from raw values 'melee_defense_bonus': double_values.get('29', double_values.get(29, -1.0)), 'magic_defense_bonus': double_values.get('150', double_values.get(150, -1.0)), 'missile_defense_bonus': double_values.get('149', double_values.get(149, -1.0)), 'elemental_damage_vs_monsters': double_values.get('152', double_values.get(152, -1.0)), 'mana_conversion_bonus': double_values.get('144', double_values.get(144, -1.0)), + + # Advanced damage properties + 'cleaving': int_values.get('292', int_values.get(292, -1)), + 'elemental_damage_bonus': int_values.get('204', int_values.get(204, -1)), + 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), + 'damage_over_time': int_values.get('318', int_values.get(318, -1)), + + # Resistances + 'resist_magic': int_values.get('36', int_values.get(36, -1)), + 'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), + 'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), + 'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), + 'life_resist_rating': int_values.get('351', int_values.get(351, -1)), + 'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), + + # Healing/Recovery + 'heal_over_time': int_values.get('312', int_values.get(312, -1)), + 'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), + + # PvP properties + 'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), + 'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), + 'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), + 'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), }, 'requirements': { 'wield_level': item_data.get('WieldLevel', -1), @@ -602,12 +939,55 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: 'imbue': item_data.get('Imbue'), 'tinks': item_data.get('Tinks', -1), 'workmanship': item_data.get('Workmanship', -1.0), - 'item_set': item_data.get('ItemSet'), + 'item_set': int_values.get('265', int_values.get(265)) if int_values.get('265', int_values.get(265)) else None, + + # Advanced tinkering + 'num_times_tinkered': int_values.get('171', int_values.get(171, -1)), + 'free_tinkers_bitfield': int_values.get('264', int_values.get(264, -1)), + 'num_items_in_material': int_values.get('170', int_values.get(170, -1)), + + # Additional imbue effects + 'imbue_attempts': int_values.get('205', int_values.get(205, -1)), + 'imbue_successes': int_values.get('206', int_values.get(206, -1)), + 'imbued_effect2': int_values.get('303', int_values.get(303, -1)), + 'imbued_effect3': int_values.get('304', int_values.get(304, -1)), + 'imbued_effect4': int_values.get('305', int_values.get(305, -1)), + 'imbued_effect5': int_values.get('306', int_values.get(306, -1)), + 'imbue_stacking_bits': int_values.get('311', int_values.get(311, -1)), + + # Set information + 'equipment_set_extra': int_values.get('321', int_values.get(321, -1)), + + # Special properties + 'aetheria_bitfield': int_values.get('322', int_values.get(322, -1)), + 'heritage_specific_armor': int_values.get('324', int_values.get(324, -1)), + + # Cooldowns + 'shared_cooldown': int_values.get('280', int_values.get(280, -1)), }, 'ratings': { - 'damage_rating': item_data.get('DamRating', -1), - 'crit_rating': item_data.get('CritRating', -1), - 'heal_boost_rating': item_data.get('HealBoostRating', -1), + 'damage_rating': int_values.get('307', int_values.get(307, -1)), # DamageRating + 'crit_rating': int_values.get('313', int_values.get(313, -1)), # CritRating + 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), # CritDamageRating + 'heal_boost_rating': int_values.get('323', int_values.get(323, -1)), # HealingBoostRating + + # Additional ratings + 'weakness_rating': int_values.get('329', int_values.get(329, -1)), + 'nether_over_time': int_values.get('330', int_values.get(330, -1)), + + # Gear totals + 'gear_damage': int_values.get('370', int_values.get(370, -1)), + 'gear_damage_resist': int_values.get('371', int_values.get(371, -1)), + 'gear_crit': int_values.get('372', int_values.get(372, -1)), + 'gear_crit_resist': int_values.get('373', int_values.get(373, -1)), + 'gear_crit_damage': int_values.get('374', int_values.get(374, -1)), + 'gear_crit_damage_resist': int_values.get('375', int_values.get(375, -1)), + 'gear_healing_boost': int_values.get('376', int_values.get(376, -1)), + 'gear_max_health': int_values.get('379', int_values.get(379, -1)), + 'gear_nether_resist': int_values.get('377', int_values.get(377, -1)), + 'gear_life_resist': int_values.get('378', int_values.get(378, -1)), + 'gear_overpower': int_values.get('388', int_values.get(388, -1)), + 'gear_overpower_resist': int_values.get('389', int_values.get(389, -1)), } } @@ -726,17 +1106,41 @@ async def process_inventory(inventory: InventoryItem): timestamp = timestamp.replace(tzinfo=None) # Simple INSERT since we cleared the table first + basic = properties['basic'] item_stmt = sa.insert(Item).values( character_name=inventory.character_name, item_id=item_id, timestamp=timestamp, - name=properties['basic']['name'], - icon=properties['basic']['icon'], - object_class=properties['basic']['object_class'], - value=properties['basic']['value'], - burden=properties['basic']['burden'], - has_id_data=properties['basic']['has_id_data'], - last_id_time=item_data.get('LastIdTime', 0) + name=basic['name'], + icon=basic['icon'], + object_class=basic['object_class'], + value=basic['value'], + burden=basic['burden'], + has_id_data=basic['has_id_data'], + last_id_time=item_data.get('LastIdTime', 0), + + # Equipment status + current_wielded_location=basic['current_wielded_location'], + + # Item state + bonded=basic['bonded'], + attuned=basic['attuned'], + unique=basic['unique'], + + # Stack/Container properties + stack_size=basic['stack_size'], + max_stack_size=basic['max_stack_size'], + items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, + containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, + + # Durability + structure=basic['structure'] if basic['structure'] != -1 else None, + max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, + + # Special item flags + rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, + lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, + remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, ).returning(Item.id) result = await database.fetch_one(item_stmt) @@ -754,6 +1158,63 @@ async def process_inventory(inventory: InventoryItem): ) await database.execute(combat_stmt) + # Store requirements if applicable + requirements = properties['requirements'] + if any(v not in [-1, None, ''] for v in requirements.values()): + req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( + item_id=db_item_id, + **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) + ) + await database.execute(req_stmt) + + # Store enhancements - always create record to capture item_set data + enhancements = properties['enhancements'] + enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) + ) + await database.execute(enh_stmt) + + # Store ratings if applicable + ratings = properties['ratings'] + if any(v not in [-1, -1.0, None] for v in ratings.values()): + rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) + ) + await database.execute(rat_stmt) + + # Store spell data if applicable + spells = item_data.get('Spells', []) + active_spells = item_data.get('ActiveSpells', []) + all_spells = set(spells + active_spells) + + if all_spells: + # First delete existing spells for this item + await database.execute( + "DELETE FROM item_spells WHERE item_id = :item_id", + {"item_id": db_item_id} + ) + + # Insert all spells for this item + for spell_id in all_spells: + is_active = spell_id in active_spells + spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( + item_id=db_item_id, + spell_id=spell_id, + is_active=is_active + ).on_conflict_do_nothing() + await database.execute(spell_stmt) + # Store raw data for completeness raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( item_id=db_item_id, @@ -976,16 +1437,42 @@ async def get_character_inventory( processed_item['workmanship_text'] = translate_workmanship(int(enhancements['workmanship'])) if enhancements.get('item_set'): processed_item['item_set'] = enhancements['item_set'] + # Add equipment set name translation + set_id = str(enhancements['item_set']).strip() + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + if set_id in attribute_set_info: + processed_item['item_set_name'] = attribute_set_info[set_id] + else: + processed_item['item_set_name'] = f"Set {set_id}" - # Add comprehensive ratings + # Add comprehensive ratings (use gear totals as fallback for armor/clothing) if 'ratings' in translated_props: ratings = translated_props['ratings'] + + # Damage rating: use individual rating or gear total if ratings.get('damage_rating', -1) != -1: processed_item['damage_rating'] = ratings['damage_rating'] + elif ratings.get('gear_damage', -1) > 0: + processed_item['damage_rating'] = ratings['gear_damage'] + + # Crit rating if ratings.get('crit_rating', -1) != -1: processed_item['crit_rating'] = ratings['crit_rating'] + elif ratings.get('gear_crit', -1) > 0: + processed_item['crit_rating'] = ratings['gear_crit'] + + # Crit damage rating: use individual rating or gear total + if ratings.get('crit_damage_rating', -1) != -1: + processed_item['crit_damage_rating'] = ratings['crit_damage_rating'] + elif ratings.get('gear_crit_damage', -1) > 0: + processed_item['crit_damage_rating'] = ratings['gear_crit_damage'] + + # Heal boost rating: use individual rating or gear total if ratings.get('heal_boost_rating', -1) != -1: processed_item['heal_boost_rating'] = ratings['heal_boost_rating'] + elif ratings.get('gear_healing_boost', -1) > 0: + processed_item['heal_boost_rating'] = ratings['gear_healing_boost'] # Apply material prefix to item name if material exists if processed_item.get('material_name') and processed_item.get('name'): @@ -1019,6 +1506,48 @@ async def health_check(): """Health check endpoint.""" return {"status": "healthy", "service": "inventory-service"} +@app.get("/sets/list") +async def list_equipment_sets(): + """Get all unique equipment set names from the database.""" + try: + # Get equipment set IDs (the numeric collection sets) + query = """ + SELECT DISTINCT enh.item_set, COUNT(*) as item_count + FROM item_enhancements enh + WHERE enh.item_set IS NOT NULL AND enh.item_set != '' + GROUP BY enh.item_set + ORDER BY item_count DESC, enh.item_set + """ + rows = await database.fetch_all(query) + + # Get AttributeSetInfo mapping from enum database + attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) + + # Map equipment sets to proper names + equipment_sets = [] + for row in rows: + set_id = row["item_set"] + item_count = row["item_count"] + + # Get proper name from AttributeSetInfo mapping + set_name = attribute_set_info.get(set_id, f"Unknown Set {set_id}") + + equipment_sets.append({ + "id": set_id, + "name": set_name, + "item_count": item_count + }) + + return { + "equipment_sets": equipment_sets, + "total_sets": len(equipment_sets) + } + + except Exception as e: + logger.error(f"Failed to list equipment sets: {e}") + raise HTTPException(status_code=500, detail="Failed to list equipment sets") + + @app.get("/enum-info") async def get_enum_info(): """Get information about available enum translations.""" @@ -1107,6 +1636,794 @@ async def get_character_inventory_raw(character_name: str): "items": processed_items } + +# =================================================================== +# INVENTORY SEARCH API ENDPOINTS +# =================================================================== + +@app.get("/search/items") +async def search_items( + # Text search + text: str = Query(None, description="Search item names, descriptions, or properties"), + character: str = Query(None, description="Limit search to specific character"), + characters: str = Query(None, description="Comma-separated list of character names"), + include_all_characters: bool = Query(False, description="Search across all characters"), + + # Equipment filtering + equipment_status: str = Query(None, description="equipped, unequipped, or all"), + equipment_slot: int = Query(None, description="Equipment slot mask (e.g., 1=head, 512=chest)"), + + # Item category filtering + armor_only: bool = Query(False, description="Show only armor items"), + jewelry_only: bool = Query(False, description="Show only jewelry items"), + weapon_only: bool = Query(False, description="Show only weapon items"), + + # Spell filtering + has_spell: str = Query(None, description="Must have this specific spell (by name)"), + spell_contains: str = Query(None, description="Spell name contains this text"), + legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names"), + + # Combat properties + min_damage: int = Query(None, description="Minimum damage"), + max_damage: int = Query(None, description="Maximum damage"), + min_armor: int = Query(None, description="Minimum armor level"), + max_armor: int = Query(None, description="Maximum armor level"), + min_attack_bonus: float = Query(None, description="Minimum attack bonus"), + min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating"), + min_damage_rating: int = Query(None, description="Minimum damage rating"), + min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"), + + # Requirements + max_level: int = Query(None, description="Maximum wield level requirement"), + min_level: int = Query(None, description="Minimum wield level requirement"), + + # Enhancements + material: str = Query(None, description="Material type (partial match)"), + min_workmanship: float = Query(None, description="Minimum workmanship"), + has_imbue: bool = Query(None, description="Has imbue effects"), + item_set: str = Query(None, description="Item set ID (single set)"), + item_sets: str = Query(None, description="Comma-separated list of item set IDs"), + min_tinks: int = Query(None, description="Minimum tinker count"), + + # Item state + bonded: bool = Query(None, description="Bonded status"), + attuned: bool = Query(None, description="Attuned status"), + unique: bool = Query(None, description="Unique item status"), + is_rare: bool = Query(None, description="Rare item status"), + min_condition: int = Query(None, description="Minimum condition percentage"), + + # Value/utility + min_value: int = Query(None, description="Minimum item value"), + max_value: int = Query(None, description="Maximum item value"), + max_burden: int = Query(None, description="Maximum burden"), + + # Sorting and pagination + sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"), + sort_dir: str = Query("asc", description="Sort direction: asc or desc"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(200, ge=1, le=2000, description="Items per page") +): + """ + Search items across characters with comprehensive filtering options. + """ + try: + # Build base query - include raw data for comprehensive translations + query_parts = [""" + SELECT DISTINCT + i.id as db_item_id, + i.character_name, + i.name, + i.icon, + i.object_class, + i.value, + i.burden, + i.current_wielded_location, + i.bonded, + i.attuned, + i.unique, + i.stack_size, + i.max_stack_size, + i.structure, + i.max_structure, + i.rare_id, + COALESCE(cs.max_damage, -1) as max_damage, + COALESCE(cs.armor_level, -1) as armor_level, + COALESCE(cs.attack_bonus, -1.0) as attack_bonus, + GREATEST( + COALESCE((rd.int_values->>'314')::int, -1), + COALESCE((rd.int_values->>'374')::int, -1) + ) as crit_damage_rating, + GREATEST( + COALESCE((rd.int_values->>'307')::int, -1), + COALESCE((rd.int_values->>'370')::int, -1) + ) as damage_rating, + GREATEST( + COALESCE((rd.int_values->>'323')::int, -1), + COALESCE((rd.int_values->>'376')::int, -1) + ) as heal_boost_rating, + COALESCE(req.wield_level, -1) as wield_level, + COALESCE(enh.material, '') as material, + COALESCE(enh.workmanship, -1.0) as workmanship, + COALESCE(enh.imbue, '') as imbue, + COALESCE(enh.tinks, -1) as tinks, + COALESCE(enh.item_set, '') as item_set, + rd.original_json + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_requirements req ON i.id = req.item_id + LEFT JOIN item_enhancements enh ON i.id = enh.item_id + LEFT JOIN item_ratings rt ON i.id = rt.item_id + LEFT JOIN item_raw_data rd ON i.id = rd.item_id + """] + + conditions = [] + params = {} + + # Character filtering + if character: + conditions.append("i.character_name = :character") + params["character"] = character + elif characters: + # Handle comma-separated list of characters + character_list = [char.strip() for char in characters.split(',') if char.strip()] + if character_list: + # Create parameterized IN clause + char_params = [] + for i, char_name in enumerate(character_list): + param_name = f"char_{i}" + char_params.append(f":{param_name}") + params[param_name] = char_name + conditions.append(f"i.character_name IN ({', '.join(char_params)})") + else: + return { + "error": "Empty characters list provided", + "items": [], + "total_count": 0 + } + elif not include_all_characters: + # Default to requiring character parameter if not searching all + return { + "error": "Must specify character, characters, or set include_all_characters=true", + "items": [], + "total_count": 0 + } + + # Text search (name) + if text: + conditions.append("i.name ILIKE :text") + params["text"] = f"%{text}%" + + # Item category filtering + if armor_only: + # Armor: ObjectClass 2 (Clothing) or 3 (Armor) with armor_level > 0 + conditions.append("(i.object_class IN (2, 3) AND COALESCE(cs.armor_level, 0) > 0)") + elif jewelry_only: + # Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets + conditions.append("i.object_class = 4") + elif weapon_only: + # Weapons: ObjectClass 6 (MeleeWeapon), 7 (MissileWeapon), 8 (Caster) with max_damage > 0 + conditions.append("(i.object_class IN (6, 7, 8) AND COALESCE(cs.max_damage, 0) > 0)") + + # Spell filtering - need to join with item_spells and use spell database + spell_join_added = False + if has_spell or spell_contains or legendary_cantrips: + query_parts[0] = query_parts[0].replace( + "LEFT JOIN item_ratings rt ON i.id = rt.item_id", + """LEFT JOIN item_ratings rt ON i.id = rt.item_id + LEFT JOIN item_spells sp ON i.id = sp.item_id""" + ) + spell_join_added = True + + spell_conditions = [] + + if has_spell: + # Look up spell ID by exact name match in ENUM_MAPPINGS + spell_id = None + spells = ENUM_MAPPINGS.get('spells', {}) + for sid, spell_data in spells.items(): + if isinstance(spell_data, dict) and spell_data.get('name', '').lower() == has_spell.lower(): + spell_id = sid + break + + if spell_id: + spell_conditions.append("sp.spell_id = :has_spell_id") + params["has_spell_id"] = spell_id + else: + # If spell not found by exact name, no results + conditions.append("1 = 0") + + if spell_contains: + # Find all spell IDs that contain the text + matching_spell_ids = [] + spells = ENUM_MAPPINGS.get('spells', {}) + for sid, spell_data in spells.items(): + if isinstance(spell_data, dict): + spell_name = spell_data.get('name', '').lower() + if spell_contains.lower() in spell_name: + matching_spell_ids.append(sid) + + if matching_spell_ids: + spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})") + else: + # If no spells found containing the text, no results + conditions.append("1 = 0") + + if legendary_cantrips: + # Parse comma-separated list of cantrip names + cantrip_names = [name.strip() for name in legendary_cantrips.split(',')] + matching_spell_ids = [] + spells = ENUM_MAPPINGS.get('spells', {}) + + logger.info(f"Looking for cantrips: {cantrip_names}") + + for cantrip_name in cantrip_names: + found_match = False + for sid, spell_data in spells.items(): + if isinstance(spell_data, dict): + spell_name = spell_data.get('name', '').lower() + # Match cantrip name (flexible matching) + if cantrip_name.lower() in spell_name or spell_name in cantrip_name.lower(): + matching_spell_ids.append(sid) + found_match = True + logger.info(f"Found spell match: {spell_name} (ID: {sid}) for cantrip: {cantrip_name}") + + if not found_match: + logger.warning(f"No spell found matching cantrip: {cantrip_name}") + + if matching_spell_ids: + # Remove duplicates + matching_spell_ids = list(set(matching_spell_ids)) + spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})") + logger.info(f"Found {len(matching_spell_ids)} matching spell IDs: {matching_spell_ids}") + else: + # If no matching cantrips found, this will return no results + logger.warning("No matching spells found for any cantrips - search will return empty results") + spell_conditions.append("sp.spell_id = -1") # Use impossible condition instead of 1=0 + + # Combine spell conditions with OR - only add if we have conditions + if spell_conditions: + conditions.append(f"({' OR '.join(spell_conditions)})") + + # Equipment status + if equipment_status == "equipped": + conditions.append("i.current_wielded_location > 0") + elif equipment_status == "unequipped": + conditions.append("i.current_wielded_location = 0") + + # Equipment slot + if equipment_slot is not None: + conditions.append("i.current_wielded_location = :equipment_slot") + params["equipment_slot"] = equipment_slot + + # Combat properties + if min_damage is not None: + conditions.append("cs.max_damage >= :min_damage") + params["min_damage"] = min_damage + if max_damage is not None: + conditions.append("cs.max_damage <= :max_damage") + params["max_damage"] = max_damage + if min_armor is not None: + conditions.append("cs.armor_level >= :min_armor") + params["min_armor"] = min_armor + if max_armor is not None: + conditions.append("cs.armor_level <= :max_armor") + params["max_armor"] = max_armor + if min_attack_bonus is not None: + conditions.append("cs.attack_bonus >= :min_attack_bonus") + params["min_attack_bonus"] = min_attack_bonus + if min_crit_damage_rating is not None: + # Check both individual rating (314) and gear total (374) + conditions.append("""( + COALESCE((rd.int_values->>'314')::int, 0) >= :min_crit_damage_rating OR + COALESCE((rd.int_values->>'374')::int, 0) >= :min_crit_damage_rating + )""") + params["min_crit_damage_rating"] = min_crit_damage_rating + if min_damage_rating is not None: + # Check both individual rating (307) and gear total (370) + conditions.append("""( + COALESCE((rd.int_values->>'307')::int, 0) >= :min_damage_rating OR + COALESCE((rd.int_values->>'370')::int, 0) >= :min_damage_rating + )""") + params["min_damage_rating"] = min_damage_rating + if min_heal_boost_rating is not None: + # Check both individual rating (323) and gear total (376) + conditions.append("""( + COALESCE((rd.int_values->>'323')::int, 0) >= :min_heal_boost_rating OR + COALESCE((rd.int_values->>'376')::int, 0) >= :min_heal_boost_rating + )""") + params["min_heal_boost_rating"] = min_heal_boost_rating + + # Requirements + if max_level is not None: + conditions.append("(req.wield_level <= :max_level OR req.wield_level IS NULL)") + params["max_level"] = max_level + if min_level is not None: + conditions.append("req.wield_level >= :min_level") + params["min_level"] = min_level + + # Enhancements + if material: + conditions.append("enh.material ILIKE :material") + params["material"] = f"%{material}%" + if min_workmanship is not None: + conditions.append("enh.workmanship >= :min_workmanship") + params["min_workmanship"] = min_workmanship + if has_imbue is not None: + if has_imbue: + conditions.append("enh.imbue IS NOT NULL AND enh.imbue != ''") + else: + conditions.append("(enh.imbue IS NULL OR enh.imbue = '')") + if item_set: + conditions.append("enh.item_set = :item_set") + params["item_set"] = item_set + elif item_sets: + # Handle comma-separated list of item set IDs + set_list = [set_id.strip() for set_id in item_sets.split(',') if set_id.strip()] + if set_list: + # Create parameterized IN clause + set_params = [] + for i, set_id in enumerate(set_list): + param_name = f"set_{i}" + set_params.append(f":{param_name}") + params[param_name] = set_id + conditions.append(f"enh.item_set IN ({', '.join(set_params)})") + else: + # Empty sets list - no results + conditions.append("1 = 0") + if min_tinks is not None: + conditions.append("enh.tinks >= :min_tinks") + params["min_tinks"] = min_tinks + + # Item state + if bonded is not None: + conditions.append("i.bonded > 0" if bonded else "i.bonded = 0") + if attuned is not None: + conditions.append("i.attuned > 0" if attuned else "i.attuned = 0") + if unique is not None: + conditions.append("i.unique = :unique") + params["unique"] = unique + if is_rare is not None: + if is_rare: + conditions.append("i.rare_id IS NOT NULL AND i.rare_id > 0") + else: + conditions.append("(i.rare_id IS NULL OR i.rare_id <= 0)") + if min_condition is not None: + conditions.append("((i.structure * 100.0 / NULLIF(i.max_structure, 0)) >= :min_condition OR i.max_structure IS NULL)") + params["min_condition"] = min_condition + + # Value/utility + if min_value is not None: + conditions.append("i.value >= :min_value") + params["min_value"] = min_value + if max_value is not None: + conditions.append("i.value <= :max_value") + params["max_value"] = max_value + if max_burden is not None: + conditions.append("i.burden <= :max_burden") + params["max_burden"] = max_burden + + # Build WHERE clause + if conditions: + query_parts.append("WHERE " + " AND ".join(conditions)) + + # Add ORDER BY + sort_mapping = { + "name": "i.name", + "value": "i.value", + "damage": "cs.max_damage", + "armor": "cs.armor_level", + "workmanship": "enh.workmanship", + "level": "req.wield_level", + "damage_rating": "damage_rating", + "crit_damage_rating": "crit_damage_rating", + "heal_boost_rating": "heal_boost_rating" + } + sort_field = sort_mapping.get(sort_by, "i.name") + sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" + query_parts.append(f"ORDER BY {sort_field} {sort_direction}") + + # Add pagination + offset = (page - 1) * limit + query_parts.append(f"LIMIT {limit} OFFSET {offset}") + + # Execute query + query = "\n".join(query_parts) + rows = await database.fetch_all(query, params) + + # Get total count for pagination - build separate count query + count_query = """ + SELECT COUNT(DISTINCT i.id) + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_requirements req ON i.id = req.item_id + LEFT JOIN item_enhancements enh ON i.id = enh.item_id + LEFT JOIN item_ratings rt ON i.id = rt.item_id + LEFT JOIN item_raw_data rd ON i.id = rd.item_id + """ + + # Add spell join to count query if needed + if spell_join_added: + count_query += "\n LEFT JOIN item_spells sp ON i.id = sp.item_id" + + if conditions: + count_query += "\nWHERE " + " AND ".join(conditions) + + count_result = await database.fetch_one(count_query, params) + total_count = int(count_result[0]) if count_result else 0 + + # Format results with comprehensive translations (like individual inventory endpoint) + items = [] + for row in rows: + item = dict(row) + + # Add computed properties + item['is_equipped'] = item['current_wielded_location'] > 0 + item['is_bonded'] = item['bonded'] > 0 + item['is_attuned'] = item['attuned'] > 0 + item['is_rare'] = (item['rare_id'] or 0) > 0 + + # Calculate condition percentage + if item['max_structure'] and item['max_structure'] > 0: + item['condition_percent'] = round((item['structure'] or 0) * 100.0 / item['max_structure'], 1) + else: + item['condition_percent'] = None + + # Apply comprehensive translations from original_json (like individual inventory endpoint) + if item.get('original_json'): + original_json = item['original_json'] + # Handle case where original_json might be stored as string + if isinstance(original_json, str): + try: + original_json = json.loads(original_json) + except (json.JSONDecodeError, TypeError): + original_json = {} + + if original_json: + # Extract properties and get comprehensive translations + properties = extract_item_properties(original_json) + + # Add material translation and prefixing + if item.get('material') or properties.get('translations', {}).get('material_name'): + material_name = None + if item.get('material'): + material_name = translate_material_type(item['material']) + elif properties.get('translations', {}).get('material_name'): + material_name = properties['translations']['material_name'] + + if material_name: + item['material_name'] = material_name + # Apply material prefix to item name + original_name = item['name'] + if not original_name.lower().startswith(material_name.lower()): + item['name'] = f"{material_name} {original_name}" + item['original_name'] = original_name + + # Add object class translation + if item.get('object_class'): + item['object_class_name'] = translate_object_class(item['object_class'], original_json) + + # Add spell information + if 'spells' in properties: + spell_info = properties['spells'] + if spell_info.get('spells'): + item['spells'] = spell_info['spells'] + item['spell_names'] = [spell.get('name', '') for spell in spell_info['spells'] if spell.get('name')] + if spell_info.get('active_spells'): + item['active_spells'] = spell_info['active_spells'] + + # Add coverage calculation from coverage mask + int_values = original_json.get('IntValues', {}) + coverage_value = None + + # Check for coverage mask in correct location (218103821 = Coverage_Decal) + if '218103821' in int_values: + coverage_value = int_values['218103821'] + elif 218103821 in int_values: + coverage_value = int_values[218103821] + + if coverage_value and coverage_value > 0: + coverage_parts = translate_coverage_mask(coverage_value) + if coverage_parts: + item['coverage'] = ', '.join(coverage_parts) + else: + item['coverage'] = f"Coverage_{coverage_value}" + else: + item['coverage'] = None + + # Add sophisticated equipment slot translation using Mag-SuitBuilder logic + # Use both EquipableSlots_Decal and Coverage for armor reduction + if original_json and 'IntValues' in original_json: + equippable_slots = int_values.get('218103822', int_values.get(218103822, 0)) + coverage_value = int_values.get('218103821', int_values.get(218103821, 0)) + + # Add debug info to help troubleshoot slot translation issues + if 'legging' in item['name'].lower() or 'greave' in item['name'].lower(): + item['debug_slot_info'] = { + 'equippable_slots': equippable_slots, + 'coverage_value': coverage_value, + 'current_wielded_location': item.get('current_wielded_location', 0) + } + + if equippable_slots and int(equippable_slots) > 0: + # Check if item has material (can be tailored) + has_material = bool(item.get('material_name') and item.get('material_name') != '') + + # Get sophisticated slot options using Mag-SuitBuilder logic + slot_options = get_sophisticated_slot_options( + int(equippable_slots), + int(coverage_value) if coverage_value else 0, + has_material + ) + + # Translate all slot options to friendly names + slot_names = [] + for slot_option in slot_options: + slot_name = translate_equipment_slot(slot_option) + if slot_name and slot_name not in slot_names: + slot_names.append(slot_name) + + item['slot_name'] = ', '.join(slot_names) if slot_names else f"Slot_{equippable_slots}" + else: + item['slot_name'] = "Unknown" + else: + item['slot_name'] = "Unknown" + + # Use gear totals as display ratings when individual ratings don't exist + # For armor/clothing, ratings are often stored as gear totals (370, 372, 374) + if item.get('damage_rating', -1) == -1 and 'gear_damage' in properties.get('ratings', {}): + gear_damage = properties['ratings'].get('gear_damage', -1) + if gear_damage > 0: + item['damage_rating'] = gear_damage + else: + item['damage_rating'] = None + elif item.get('damage_rating', -1) == -1: + item['damage_rating'] = None + + if item.get('crit_damage_rating', -1) == -1 and 'gear_crit_damage' in properties.get('ratings', {}): + gear_crit_damage = properties['ratings'].get('gear_crit_damage', -1) + if gear_crit_damage > 0: + item['crit_damage_rating'] = gear_crit_damage + else: + item['crit_damage_rating'] = None + elif item.get('crit_damage_rating', -1) == -1: + item['crit_damage_rating'] = None + + if item.get('heal_boost_rating', -1) == -1 and 'gear_healing_boost' in properties.get('ratings', {}): + gear_healing_boost = properties['ratings'].get('gear_healing_boost', -1) + if gear_healing_boost > 0: + item['heal_boost_rating'] = gear_healing_boost + else: + item['heal_boost_rating'] = None + elif item.get('heal_boost_rating', -1) == -1: + item['heal_boost_rating'] = None + + # Add equipment set name translation + if item.get('item_set') and str(item['item_set']).strip(): + set_id = str(item['item_set']).strip() + # Get dictionary from enum database + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + if set_id in attribute_set_info: + item['item_set_name'] = attribute_set_info[set_id] + else: + # Try checking if it's in the alternative location (equipment_sets) + equipment_sets = ENUM_MAPPINGS.get('equipment_sets', {}) + if set_id in equipment_sets: + item['item_set_name'] = equipment_sets[set_id] + else: + item['item_set_name'] = f"Set {set_id}" + + # Clean up - remove raw data from response + item.pop('original_json', None) + item.pop('db_item_id', None) + + items.append(item) + + return { + "items": items, + "total_count": total_count, + "page": page, + "limit": limit, + "total_pages": (total_count + limit - 1) // limit, + "search_criteria": { + "text": text, + "character": character, + "include_all_characters": include_all_characters, + "equipment_status": equipment_status, + "filters_applied": len(conditions) + } + } + + except Exception as e: + logger.error(f"Search error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") + + +@app.get("/search/equipped/{character_name}") +async def search_equipped_items( + character_name: str, + slot: int = Query(None, description="Specific equipment slot mask") +): + """Get all equipped items for a character, optionally filtered by slot.""" + try: + conditions = ["i.character_name = :character_name", "i.current_wielded_location > 0"] + params = {"character_name": character_name} + + if slot is not None: + conditions.append("i.current_wielded_location = :slot") + params["slot"] = slot + + query = """ + SELECT + i.*, + COALESCE(cs.max_damage, -1) as max_damage, + COALESCE(cs.armor_level, -1) as armor_level, + COALESCE(cs.attack_bonus, -1.0) as attack_bonus, + COALESCE(req.wield_level, -1) as wield_level, + COALESCE(enh.material, '') as material, + COALESCE(enh.workmanship, -1.0) as workmanship, + COALESCE(enh.item_set, '') as item_set + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_requirements req ON i.id = req.item_id + LEFT JOIN item_enhancements enh ON i.id = enh.item_id + WHERE """ + " AND ".join(conditions) + """ + ORDER BY i.current_wielded_location, i.name + """ + + rows = await database.fetch_all(query, params) + + # Load EquipMask enum for slot names + equip_mask_map = {} + if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): + equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] + for k, v in equip_data.items(): + try: + equip_mask_map[int(k)] = v + except (ValueError, TypeError): + pass + + items = [] + for row in rows: + item = dict(row) + item['slot_name'] = equip_mask_map.get(item['current_wielded_location'], f"Slot_{item['current_wielded_location']}") + items.append(item) + + return { + "character_name": character_name, + "equipped_items": items, + "slot_filter": slot, + "item_count": len(items) + } + + except Exception as e: + logger.error(f"Equipped items search error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Equipped items search failed: {str(e)}") + + +@app.get("/search/upgrades/{character_name}/{slot}") +async def find_equipment_upgrades( + character_name: str, + slot: int, + upgrade_type: str = Query("damage", description="What to optimize for: damage, armor, workmanship, value") +): + """Find potential equipment upgrades for a specific slot.""" + try: + # Get currently equipped item in this slot + current_query = """ + SELECT i.*, cs.max_damage, cs.armor_level, enh.workmanship + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_enhancements enh ON i.id = enh.item_id + WHERE i.character_name = :character_name + AND i.current_wielded_location = :slot + """ + + current_item = await database.fetch_one(current_query, { + "character_name": character_name, + "slot": slot + }) + + # Find all unequipped items that could be equipped in this slot + # Check ValidLocations or infer from similar equipped items + upgrade_query = """ + SELECT DISTINCT + i.character_name, + i.name, + i.value, + i.burden, + COALESCE(cs.max_damage, -1) as max_damage, + COALESCE(cs.armor_level, -1) as armor_level, + COALESCE(enh.workmanship, -1.0) as workmanship, + COALESCE(req.wield_level, -1) as wield_level + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_enhancements enh ON i.id = enh.item_id + LEFT JOIN item_requirements req ON i.id = req.item_id + WHERE i.current_wielded_location = 0 + AND i.object_class = :object_class + """ + + params = {} + if current_item: + params["object_class"] = current_item["object_class"] + + # Add upgrade criteria based on current item + if upgrade_type == "damage" and current_item.get("max_damage", -1) > 0: + upgrade_query += " AND cs.max_damage > :current_damage" + params["current_damage"] = current_item["max_damage"] + elif upgrade_type == "armor" and current_item.get("armor_level", -1) > 0: + upgrade_query += " AND cs.armor_level > :current_armor" + params["current_armor"] = current_item["armor_level"] + elif upgrade_type == "workmanship" and current_item.get("workmanship", -1) > 0: + upgrade_query += " AND enh.workmanship > :current_workmanship" + params["current_workmanship"] = current_item["workmanship"] + elif upgrade_type == "value": + upgrade_query += " AND i.value > :current_value" + params["current_value"] = current_item["value"] + else: + # No current item, show all available items for this slot type + # We'll need to infer object class from slot - this is a simplified approach + params["object_class"] = 1 # Default to generic + + # Add sorting based on upgrade type + if upgrade_type == "damage": + upgrade_query += " ORDER BY cs.max_damage DESC" + elif upgrade_type == "armor": + upgrade_query += " ORDER BY cs.armor_level DESC" + elif upgrade_type == "workmanship": + upgrade_query += " ORDER BY enh.workmanship DESC" + else: + upgrade_query += " ORDER BY i.value DESC" + + upgrade_query += " LIMIT 20" # Limit to top 20 upgrades + + upgrades = await database.fetch_all(upgrade_query, params) + + return { + "character_name": character_name, + "slot": slot, + "upgrade_type": upgrade_type, + "current_item": dict(current_item) if current_item else None, + "potential_upgrades": [dict(row) for row in upgrades], + "upgrade_count": len(upgrades) + } + + except Exception as e: + logger.error(f"Equipment upgrades search error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Equipment upgrades search failed: {str(e)}") + + +@app.get("/characters/list") +async def list_inventory_characters(): + """List all characters that have inventory data.""" + try: + query = """ + SELECT character_name, COUNT(*) as item_count, MAX(timestamp) as last_updated + FROM items + GROUP BY character_name + ORDER BY character_name + """ + rows = await database.fetch_all(query) + + characters = [] + for row in rows: + characters.append({ + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"] + }) + + return { + "characters": characters, + "total_characters": len(characters) + } + + except Exception as e: + logger.error(f"Failed to list inventory characters: {e}") + raise HTTPException(status_code=500, detail="Failed to list inventory characters") + + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/main.py b/main.py index 173c83ac..7ec722d2 100644 --- a/main.py +++ b/main.py @@ -13,8 +13,8 @@ import sys from typing import Dict, List, Any from pathlib import Path -from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse +from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request +from fastapi.responses import JSONResponse, Response from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from fastapi.encoders import jsonable_encoder @@ -386,6 +386,64 @@ async def get_total_rares(): raise HTTPException(status_code=500, detail="Internal server error") +# --- GET Spawn Heat Map Endpoint --------------------------------- +@app.get("/spawns/heatmap") +async def get_spawn_heatmap_data( + hours: int = Query(24, ge=1, le=168, description="Lookback window in hours (1-168)"), + limit: int = Query(10000, ge=100, le=50000, description="Maximum number of spawn points to return") +): + """ + Aggregate spawn locations for heat-map visualization. + + Returns spawn event coordinates grouped by location with intensity counts + for the specified time window. + + Response format: + { + "spawn_points": [{"ew": float, "ns": float, "intensity": int}, ...], + "total_points": int, + "timestamp": "UTC-ISO" + } + """ + try: + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + + # Aggregate spawn events by coordinates within time window + query = """ + SELECT ew, ns, COUNT(*) AS spawn_count + FROM spawn_events + WHERE timestamp >= :cutoff + GROUP BY ew, ns + ORDER BY spawn_count DESC + LIMIT :limit + """ + + rows = await database.fetch_all(query, {"cutoff": cutoff, "limit": limit}) + + spawn_points = [ + { + "ew": float(row["ew"]), + "ns": float(row["ns"]), + "intensity": int(row["spawn_count"]) + } + for row in rows + ] + + result = { + "spawn_points": spawn_points, + "total_points": len(spawn_points), + "timestamp": datetime.now(timezone.utc).isoformat(), + "hours_window": hours + } + + logger.debug(f"Heat map data: {len(spawn_points)} unique spawn locations from last {hours} hours") + return JSONResponse(content=jsonable_encoder(result)) + + except Exception as e: + logger.error(f"Heat map query failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Spawn heat map query failed") + + # --- GET Inventory Endpoints --------------------------------- @app.get("/inventory/{character_name}") async def get_character_inventory(character_name: str): @@ -514,6 +572,230 @@ async def list_characters_with_inventories(): raise HTTPException(status_code=500, detail="Internal server error") +# --- Inventory Service Character List Proxy --------------------- +@app.get("/inventory-characters") +async def get_inventory_characters(): + """Get character list from inventory service - proxy to avoid routing conflicts.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{INVENTORY_SERVICE_URL}/characters/list") + + if response.status_code == 200: + return JSONResponse(content=response.json()) + else: + logger.error(f"Inventory service returned {response.status_code}: {response.text}") + raise HTTPException(status_code=response.status_code, detail="Failed to get characters from inventory service") + + except Exception as e: + logger.error(f"Failed to proxy inventory characters request: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to get inventory characters") + + +# --- Inventory Search Service Proxy Endpoints ------------------- +@app.get("/search/items") +async def search_items_proxy( + text: str = Query(None, description="Search item names, descriptions, or properties"), + character: str = Query(None, description="Limit search to specific character"), + include_all_characters: bool = Query(False, description="Search across all characters"), + equipment_status: str = Query(None, description="equipped, unequipped, or all"), + equipment_slot: int = Query(None, description="Equipment slot mask"), + # Item category filtering + armor_only: bool = Query(False, description="Show only armor items"), + jewelry_only: bool = Query(False, description="Show only jewelry items"), + weapon_only: bool = Query(False, description="Show only weapon items"), + # Spell filtering + has_spell: str = Query(None, description="Must have this specific spell (by name)"), + spell_contains: str = Query(None, description="Spell name contains this text"), + legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names"), + # Combat properties + min_damage: int = Query(None, description="Minimum damage"), + max_damage: int = Query(None, description="Maximum damage"), + min_armor: int = Query(None, description="Minimum armor level"), + max_armor: int = Query(None, description="Maximum armor level"), + min_attack_bonus: float = Query(None, description="Minimum attack bonus"), + min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating"), + min_damage_rating: int = Query(None, description="Minimum damage rating"), + min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"), + max_level: int = Query(None, description="Maximum wield level requirement"), + min_level: int = Query(None, description="Minimum wield level requirement"), + material: str = Query(None, description="Material type (partial match)"), + min_workmanship: float = Query(None, description="Minimum workmanship"), + has_imbue: bool = Query(None, description="Has imbue effects"), + item_set: str = Query(None, description="Item set name (partial match)"), + min_tinks: int = Query(None, description="Minimum tinker count"), + bonded: bool = Query(None, description="Bonded status"), + attuned: bool = Query(None, description="Attuned status"), + unique: bool = Query(None, description="Unique item status"), + is_rare: bool = Query(None, description="Rare item status"), + min_condition: int = Query(None, description="Minimum condition percentage"), + min_value: int = Query(None, description="Minimum item value"), + max_value: int = Query(None, description="Maximum item value"), + max_burden: int = Query(None, description="Maximum burden"), + sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship"), + sort_dir: str = Query("asc", description="Sort direction: asc or desc"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(50, ge=1, le=200, description="Items per page") +): + """Proxy to inventory service comprehensive item search.""" + try: + # Build query parameters + params = {} + if text: params["text"] = text + if character: params["character"] = character + if include_all_characters: params["include_all_characters"] = include_all_characters + if equipment_status: params["equipment_status"] = equipment_status + if equipment_slot is not None: params["equipment_slot"] = equipment_slot + # Category filtering + if armor_only: params["armor_only"] = armor_only + if jewelry_only: params["jewelry_only"] = jewelry_only + if weapon_only: params["weapon_only"] = weapon_only + # Spell filtering + if has_spell: params["has_spell"] = has_spell + if spell_contains: params["spell_contains"] = spell_contains + if legendary_cantrips: params["legendary_cantrips"] = legendary_cantrips + # Combat properties + if min_damage is not None: params["min_damage"] = min_damage + if max_damage is not None: params["max_damage"] = max_damage + if min_armor is not None: params["min_armor"] = min_armor + if max_armor is not None: params["max_armor"] = max_armor + if min_attack_bonus is not None: params["min_attack_bonus"] = min_attack_bonus + if min_crit_damage_rating is not None: params["min_crit_damage_rating"] = min_crit_damage_rating + if min_damage_rating is not None: params["min_damage_rating"] = min_damage_rating + if min_heal_boost_rating is not None: params["min_heal_boost_rating"] = min_heal_boost_rating + if max_level is not None: params["max_level"] = max_level + if min_level is not None: params["min_level"] = min_level + if material: params["material"] = material + if min_workmanship is not None: params["min_workmanship"] = min_workmanship + if has_imbue is not None: params["has_imbue"] = has_imbue + if item_set: params["item_set"] = item_set + if min_tinks is not None: params["min_tinks"] = min_tinks + if bonded is not None: params["bonded"] = bonded + if attuned is not None: params["attuned"] = attuned + if unique is not None: params["unique"] = unique + if is_rare is not None: params["is_rare"] = is_rare + if min_condition is not None: params["min_condition"] = min_condition + if min_value is not None: params["min_value"] = min_value + if max_value is not None: params["max_value"] = max_value + if max_burden is not None: params["max_burden"] = max_burden + params["sort_by"] = sort_by + params["sort_dir"] = sort_dir + params["page"] = page + params["limit"] = limit + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{INVENTORY_SERVICE_URL}/search/items", + params=params + ) + + if response.status_code == 200: + return JSONResponse(content=response.json()) + else: + logger.error(f"Inventory search service returned {response.status_code}") + raise HTTPException(status_code=response.status_code, detail="Inventory search service error") + + except httpx.RequestError as e: + logger.error(f"Could not reach inventory service: {e}") + raise HTTPException(status_code=503, detail="Inventory service unavailable") + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to search items: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/search/equipped/{character_name}") +async def search_equipped_items_proxy( + character_name: str, + slot: int = Query(None, description="Specific equipment slot mask") +): + """Proxy to inventory service equipped items search.""" + try: + params = {} + if slot is not None: + params["slot"] = slot + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{INVENTORY_SERVICE_URL}/search/equipped/{character_name}", + params=params + ) + + if response.status_code == 200: + return JSONResponse(content=response.json()) + elif response.status_code == 404: + raise HTTPException(status_code=404, detail=f"No equipped items found for character '{character_name}'") + else: + logger.error(f"Inventory service returned {response.status_code} for equipped items search") + raise HTTPException(status_code=response.status_code, detail="Inventory service error") + + except httpx.RequestError as e: + logger.error(f"Could not reach inventory service: {e}") + raise HTTPException(status_code=503, detail="Inventory service unavailable") + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to search equipped items for {character_name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/search/upgrades/{character_name}/{slot}") +async def find_equipment_upgrades_proxy( + character_name: str, + slot: int, + upgrade_type: str = Query("damage", description="What to optimize for: damage, armor, workmanship, value") +): + """Proxy to inventory service equipment upgrades search.""" + try: + params = {"upgrade_type": upgrade_type} + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{INVENTORY_SERVICE_URL}/search/upgrades/{character_name}/{slot}", + params=params + ) + + if response.status_code == 200: + return JSONResponse(content=response.json()) + elif response.status_code == 404: + raise HTTPException(status_code=404, detail=f"No upgrade options found for character '{character_name}' slot {slot}") + else: + logger.error(f"Inventory service returned {response.status_code} for upgrades search") + raise HTTPException(status_code=response.status_code, detail="Inventory service error") + + except httpx.RequestError as e: + logger.error(f"Could not reach inventory service: {e}") + raise HTTPException(status_code=503, detail="Inventory service unavailable") + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to find equipment upgrades for {character_name} slot {slot}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/sets/list") +async def list_equipment_sets_proxy(): + """Proxy to inventory service equipment sets list.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{INVENTORY_SERVICE_URL}/sets/list") + + if response.status_code == 200: + return JSONResponse(content=response.json()) + else: + logger.error(f"Inventory service returned {response.status_code} for sets list") + raise HTTPException(status_code=response.status_code, detail="Inventory service error") + + except httpx.RequestError as e: + logger.error(f"Could not reach inventory service: {e}") + raise HTTPException(status_code=503, detail="Inventory service unavailable") + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to list equipment sets: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + # -------------------- WebSocket endpoints ----------------------- ## WebSocket connection tracking # Set of browser WebSocket clients subscribed to live updates @@ -994,6 +1276,38 @@ async def serve_icon(icon_filename: str): # Icon not found raise HTTPException(status_code=404, detail="Icon not found") +# -------------------- Inventory Service Proxy --------------------------- + +@app.get("/inv/test") +async def test_inventory_route(): + """Test route to verify inventory proxy is working""" + return {"message": "Inventory proxy route is working"} + +@app.api_route("/inv/{path:path}", methods=["GET", "POST"]) +async def proxy_inventory_service(path: str, request: Request): + """Proxy all inventory service requests""" + try: + inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') + logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}") + + # Forward the request to inventory service + async with httpx.AsyncClient() as client: + response = await client.request( + method=request.method, + url=f"{inventory_service_url}/{path}", + params=request.query_params, + headers=dict(request.headers), + content=await request.body() + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers) + ) + except Exception as e: + logger.error(f"Failed to proxy inventory request: {e}") + raise HTTPException(status_code=500, detail="Inventory service unavailable") + # Icons are now served from static/icons directory # Serve SPA files (catch-all for frontend routes) # Mount the single-page application frontend (static assets) at root path diff --git a/static/index.html b/static/index.html index 25d00ada..565117bb 100644 --- a/static/index.html +++ b/static/index.html @@ -31,6 +31,21 @@ ⚔️ Total Kills: Loading... + +
+ +
+ + + + @@ -48,6 +63,7 @@
Dereth map +
diff --git a/static/script.js b/static/script.js index 8eb14fba..ba505276 100644 --- a/static/script.js +++ b/static/script.js @@ -181,6 +181,14 @@ const CHAT_COLOR_MAP = { 31: '#FFFF00' // AdminTell }; +/* ---------- Heat Map Globals ---------- */ +let heatmapCanvas, heatmapCtx; +let heatmapEnabled = false; +let heatmapData = null; +let heatTimeout = null; +const HEAT_PADDING = 50; // px beyond viewport to still draw +const HEAT_THROTTLE = 16; // ~60 fps + /** * ---------- Player Color Assignment ---------------------------- * Uses a predefined accessible color palette for player dots to ensure @@ -344,6 +352,108 @@ function pxToWorld(x, y) { return { ew, ns }; } +/* ---------- Heat Map Functions ---------- */ + +function initHeatMap() { + heatmapCanvas = document.getElementById('heatmapCanvas'); + if (!heatmapCanvas) { + console.error('Heat map canvas not found'); + return; + } + + heatmapCtx = heatmapCanvas.getContext('2d'); + + const toggle = document.getElementById('heatmapToggle'); + if (toggle) { + toggle.addEventListener('change', e => { + heatmapEnabled = e.target.checked; + if (heatmapEnabled) { + fetchHeatmapData(); + } else { + clearHeatmap(); + } + }); + } + + window.addEventListener('resize', debounce(() => { + if (heatmapEnabled && heatmapData) { + renderHeatmap(); + } + }, 250)); +} + +async function fetchHeatmapData() { + try { + const response = await fetch(`${API_BASE}/spawns/heatmap?hours=24&limit=50000`); + if (!response.ok) { + throw new Error(`Heat map API error: ${response.status}`); + } + + const data = await response.json(); + heatmapData = data.spawn_points; // [{ew, ns, intensity}] + console.log(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`); + renderHeatmap(); + } catch (err) { + console.error('Failed to fetch heat map data:', err); + } +} + +function renderHeatmap() { + if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) { + return; + } + + // Set canvas size to match map dimensions (1:1 DPI) + heatmapCanvas.width = imgW; + heatmapCanvas.height = imgH; + heatmapCtx.clearRect(0, 0, imgW, imgH); + + // Current visible map rect in px for viewport culling + const vw = wrap.clientWidth; + const vh = wrap.clientHeight; + const viewL = -offX / scale; + const viewT = -offY / scale; + const viewR = viewL + vw / scale; + const viewB = viewT + vh / scale; + + // Render heat map points with viewport culling + for (const point of heatmapData) { + const { x, y } = worldToPx(point.ew, point.ns); + + // Skip points outside visible area (with padding for smooth edges) + if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING || + y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) { + continue; + } + + // Smaller, more precise spots to clearly show individual spawn locations + const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5))); + + // Sharp gradient with distinct boundaries between spawn points + const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius); + gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center + gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange + gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); + + heatmapCtx.fillStyle = gradient; + heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2); + } +} + +function clearHeatmap() { + if (heatmapCtx && heatmapCanvas) { + heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height); + } +} + +function debounce(fn, ms) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), ms); + }; +} + // Show or create a stats window for a character function showStatsWindow(name) { if (statsWindows[name]) { @@ -892,6 +1002,14 @@ function clampPan() { function updateView() { clampPan(); applyTransform(); + + // Throttled heat map re-rendering during pan/zoom + if (heatmapEnabled && heatmapData && !heatTimeout) { + heatTimeout = setTimeout(() => { + renderHeatmap(); + heatTimeout = null; + }, HEAT_THROTTLE); + } } function fitToWindow() { @@ -970,6 +1088,7 @@ img.onload = () => { fitToWindow(); startPolling(); initWebSocket(); + initHeatMap(); }; /* ---------- rendering sorted list & dots ------------------------ */ @@ -1647,3 +1766,14 @@ function createMilestoneFireworks() { } } +/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */ + +/** + * Opens the dedicated inventory search page in a new browser tab. + */ +function openInventorySearch() { + // Open the dedicated inventory search page in a new tab + window.open('/inventory.html', '_blank'); +} + + diff --git a/static/style.css b/static/style.css index 5de2c5c4..29ba3b58 100644 --- a/static/style.css +++ b/static/style.css @@ -1176,3 +1176,129 @@ body.noselect, body.noselect * { .screen-shake { animation: screen-shake 0.5s ease-in-out; } + +/* ---------- Heat Map Canvas Layer ---------- */ +#heatmapCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0.85; + mix-blend-mode: screen; /* Additive blending for nice heat map effect */ +} + +/* Trails and dots use default positioning - no changes needed for layering */ + +/* Heat map toggle styling */ +.heatmap-toggle { + margin: 0 0 12px; + padding: 6px 12px; + background: var(--card); + border: 1px solid var(--accent); + border-radius: 4px; + font-size: 0.9rem; +} + +.heatmap-toggle input { + margin-right: 8px; + cursor: pointer; +} + +.heatmap-toggle label { + cursor: pointer; + user-select: none; +} + +/* Inventory search link styling */ +.inventory-search-link { + margin: 0 0 12px; + padding: 8px 12px; + background: var(--card); + border: 1px solid #4a9eff; + border-radius: 4px; + text-align: center; +} + +.inventory-search-link a { + color: #4a9eff; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + display: block; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; +} + +.inventory-search-link a:hover { + color: #fff; + background: rgba(74, 158, 255, 0.1); + border-radius: 2px; + padding: 2px 4px; + margin: -2px -4px; +} + +/* Sortable column styles for inventory tables */ +.sortable { + cursor: pointer; + user-select: none; + position: relative; + padding-right: 20px \!important; +} + +.sortable:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.results-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.results-table th, +.results-table td { + padding: 8px 12px; + border-bottom: 1px solid #333; + text-align: left; +} + +.results-table th { + background-color: #222; + font-weight: bold; + color: #eee; +} + +.results-table tr:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.text-right { + text-align: right \!important; +} + +.results-info { + margin-bottom: 10px; + color: #ccc; + font-size: 14px; +} + +/* Spell/Cantrip column styling */ +.spells-cell { + font-size: 10px; + line-height: 1.2; + max-width: 200px; + word-wrap: break-word; + vertical-align: top; +} + +.legendary-cantrip { + color: #ffd700; + font-weight: bold; +} + +.regular-spell { + color: #88ccff; +}