diff --git a/inventory-service/main.py b/inventory-service/main.py index 400f5866..f1479c1f 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -1363,7 +1363,7 @@ async def process_inventory(inventory: InventoryItem): for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) @@ -1577,7 +1577,7 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) await database.execute( @@ -1730,7 +1730,35 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): raise HTTPException(status_code=500, detail=error_msg) logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}") - return {"status": "ok", "processed": processed_count} + + # Fetch the just-upserted item with all joins and enrich it + enriched_item = None + try: + enrich_query = """ + SELECT + i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden, + i.has_id_data, i.timestamp, + i.current_wielded_location, i.container_id, i.items_capacity, + cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus, + r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement, + e.material, e.imbue, e.item_set, e.tinks, e.workmanship, + rt.damage_rating, rt.crit_rating, rt.heal_boost_rating, + rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json + FROM items i + LEFT JOIN item_combat_stats cs ON i.id = cs.item_id + LEFT JOIN item_requirements r ON i.id = r.item_id + LEFT JOIN item_enhancements e ON i.id = e.item_id + LEFT JOIN item_ratings rt ON i.id = rt.item_id + LEFT JOIN item_raw_data rd ON i.id = rd.item_id + WHERE i.id = :db_id + """ + row = await database.fetch_one(enrich_query, {"db_id": db_item_id}) + if row: + enriched_item = enrich_db_item(row) + except Exception as e: + logger.warning(f"Failed to enrich item after upsert: {e}") + + return {"status": "ok", "processed": processed_count, "item": enriched_item} @app.delete("/inventory/{character_name}/item/{item_id}", @@ -1755,7 +1783,7 @@ async def delete_inventory_item(character_name: str, item_id: int): for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} ) @@ -1771,6 +1799,224 @@ async def delete_inventory_item(character_name: str, item_id: int): return {"status": "ok", "deleted": deleted_count} +def enrich_db_item(item) -> dict: + """Enrich a single DB row (from the JOIN query) into the full frontend format. + + Takes a mapping (e.g. asyncpg Record or dict) from the items+joins query and returns + a clean dict with translated material names, spell info, combat stats, ratings, + workmanship text, mana display, etc. Identical logic to what get_character_inventory + used inline — extracted here so upsert_inventory_item can reuse it. + """ + processed_item = dict(item) + + # Get comprehensive translations from original_json + if processed_item.get('original_json'): + original_json = processed_item['original_json'] + # Handle case where original_json might be stored as string + if isinstance(original_json, str): + try: + original_json = json.loads(original_json) + except (json.JSONDecodeError, TypeError): + original_json = {} + + if original_json: + # Extract properties and get translations + properties = extract_item_properties(original_json) + + # Add translated properties to the item + processed_item['translated_properties'] = properties + + # Add material name - use material directly if it's already a string + if processed_item.get('material'): + if isinstance(processed_item['material'], str): + processed_item['material_name'] = processed_item['material'] + else: + processed_item['material_name'] = translate_material_type(processed_item['material']) + + # Add object class translation + if processed_item.get('object_class'): + processed_item['object_class_name'] = translate_object_class(processed_item['object_class'], original_json) + + # Add skill translation + if processed_item.get('equip_skill'): + processed_item['equip_skill_name'] = translate_skill(processed_item['equip_skill']) + + # Flatten the structure - move translated properties to top level + if 'translated_properties' in processed_item: + translated_props = processed_item.pop('translated_properties') + + # Move spells to top level + if 'spells' in translated_props: + processed_item['spells'] = translated_props['spells'] + + # Move translated_ints to top level for enhanced tooltips + if 'translated_ints' in translated_props: + processed_item['enhanced_properties'] = translated_props['translated_ints'] + + # Add weapon damage information + if 'weapon_damage' in translated_props: + weapon_damage = translated_props['weapon_damage'] + if weapon_damage.get('damage_range'): + processed_item['damage_range'] = weapon_damage['damage_range'] + if weapon_damage.get('damage_type'): + processed_item['damage_type'] = weapon_damage['damage_type'] + if weapon_damage.get('min_damage'): + processed_item['min_damage'] = weapon_damage['min_damage'] + + # Add weapon speed information + if 'weapon_speed' in translated_props: + speed_info = translated_props['weapon_speed'] + if speed_info.get('speed_text'): + processed_item['speed_text'] = speed_info['speed_text'] + if speed_info.get('speed_value'): + processed_item['speed_value'] = speed_info['speed_value'] + + # Add mana and spellcraft information + if 'mana_info' in translated_props: + mana_info = translated_props['mana_info'] + if mana_info.get('mana_display'): + processed_item['mana_display'] = mana_info['mana_display'] + if mana_info.get('spellcraft'): + processed_item['spellcraft'] = mana_info['spellcraft'] + if mana_info.get('current_mana'): + processed_item['current_mana'] = mana_info['current_mana'] + if mana_info.get('max_mana'): + processed_item['max_mana'] = mana_info['max_mana'] + + # Add icon overlay/underlay information from translated properties + if 'translated_ints' in translated_props: + translated_ints = translated_props['translated_ints'] + + # Icon overlay - check for the proper property name + if 'IconOverlay_Decal_DID' in translated_ints and translated_ints['IconOverlay_Decal_DID'] > 0: + processed_item['icon_overlay_id'] = translated_ints['IconOverlay_Decal_DID'] + + # Icon underlay - check for the proper property name + if 'IconUnderlay_Decal_DID' in translated_ints and translated_ints['IconUnderlay_Decal_DID'] > 0: + processed_item['icon_underlay_id'] = translated_ints['IconUnderlay_Decal_DID'] + + # Add comprehensive combat stats + if 'combat' in translated_props: + combat_stats = translated_props['combat'] + if combat_stats.get('max_damage', -1) != -1: + processed_item['max_damage'] = combat_stats['max_damage'] + if combat_stats.get('armor_level', -1) != -1: + processed_item['armor_level'] = combat_stats['armor_level'] + if combat_stats.get('damage_bonus', -1.0) != -1.0: + processed_item['damage_bonus'] = combat_stats['damage_bonus'] + if combat_stats.get('attack_bonus', -1.0) != -1.0: + processed_item['attack_bonus'] = combat_stats['attack_bonus'] + # Add missing combat bonuses + if combat_stats.get('melee_defense_bonus', -1.0) != -1.0: + processed_item['melee_defense_bonus'] = combat_stats['melee_defense_bonus'] + if combat_stats.get('magic_defense_bonus', -1.0) != -1.0: + processed_item['magic_defense_bonus'] = combat_stats['magic_defense_bonus'] + if combat_stats.get('missile_defense_bonus', -1.0) != -1.0: + processed_item['missile_defense_bonus'] = combat_stats['missile_defense_bonus'] + if combat_stats.get('elemental_damage_vs_monsters', -1.0) != -1.0: + processed_item['elemental_damage_vs_monsters'] = combat_stats['elemental_damage_vs_monsters'] + if combat_stats.get('mana_conversion_bonus', -1.0) != -1.0: + processed_item['mana_conversion_bonus'] = combat_stats['mana_conversion_bonus'] + + # Add comprehensive requirements + if 'requirements' in translated_props: + requirements = translated_props['requirements'] + if requirements.get('wield_level', -1) != -1: + processed_item['wield_level'] = requirements['wield_level'] + if requirements.get('skill_level', -1) != -1: + processed_item['skill_level'] = requirements['skill_level'] + if requirements.get('lore_requirement', -1) != -1: + processed_item['lore_requirement'] = requirements['lore_requirement'] + if requirements.get('equip_skill'): + processed_item['equip_skill'] = requirements['equip_skill'] + + # Add comprehensive enhancements + if 'enhancements' in translated_props: + enhancements = translated_props['enhancements'] + if enhancements.get('material'): + processed_item['material'] = enhancements['material'] + + # Add material information from translations + if 'translations' in translated_props: + trans = translated_props['translations'] + if trans.get('material_name'): + processed_item['material_name'] = trans['material_name'] + if trans.get('material_id'): + processed_item['material_id'] = trans['material_id'] + if trans.get('item_type_name'): + processed_item['item_type_name'] = trans['item_type_name'] + + # Continue with other enhancements + if 'enhancements' in translated_props: + enhancements = translated_props['enhancements'] + if enhancements.get('imbue'): + processed_item['imbue'] = enhancements['imbue'] + if enhancements.get('tinks', -1) != -1: + processed_item['tinks'] = enhancements['tinks'] + if enhancements.get('workmanship', -1.0) != -1.0: + processed_item['workmanship'] = enhancements['workmanship'] + processed_item['workmanship_text'] = translate_workmanship(int(enhancements['workmanship'])) + if enhancements.get('item_set'): + processed_item['item_set'] = enhancements['item_set'] + # Add equipment set name translation + set_id = str(enhancements['item_set']).strip() + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + if set_id in attribute_set_info: + processed_item['item_set_name'] = attribute_set_info[set_id] + else: + processed_item['item_set_name'] = f"Set {set_id}" + + # Add comprehensive ratings (use gear totals as fallback for armor/clothing) + if 'ratings' in translated_props: + ratings = translated_props['ratings'] + + # Damage rating: use individual rating or gear total + if ratings.get('damage_rating', -1) != -1: + processed_item['damage_rating'] = ratings['damage_rating'] + elif ratings.get('gear_damage', -1) > 0: + processed_item['damage_rating'] = ratings['gear_damage'] + + # Crit rating + if ratings.get('crit_rating', -1) != -1: + processed_item['crit_rating'] = ratings['crit_rating'] + elif ratings.get('gear_crit', -1) > 0: + processed_item['crit_rating'] = ratings['gear_crit'] + + # Crit damage rating: use individual rating or gear total + if ratings.get('crit_damage_rating', -1) != -1: + processed_item['crit_damage_rating'] = ratings['crit_damage_rating'] + elif ratings.get('gear_crit_damage', -1) > 0: + processed_item['crit_damage_rating'] = ratings['gear_crit_damage'] + + # Heal boost rating: use individual rating or gear total + if ratings.get('heal_boost_rating', -1) != -1: + processed_item['heal_boost_rating'] = ratings['heal_boost_rating'] + elif ratings.get('gear_healing_boost', -1) > 0: + processed_item['heal_boost_rating'] = ratings['gear_healing_boost'] + + # Apply material prefix to item name if material exists + if processed_item.get('material_name') and processed_item.get('name'): + original_name = processed_item['name'] + material_name = processed_item['material_name'] + + # Don't add prefix if name already starts with the material + if not original_name.lower().startswith(material_name.lower()): + processed_item['name'] = f"{material_name} {original_name}" + processed_item['original_name'] = original_name # Preserve original for reference + + # Remove raw data from response (keep clean output) + processed_item.pop('int_values', None) + processed_item.pop('double_values', None) + processed_item.pop('string_values', None) + processed_item.pop('bool_values', None) + processed_item.pop('original_json', None) + + # Remove null values for cleaner response + processed_item = {k: v for k, v in processed_item.items() if v is not None} + return processed_item + + @app.get("/inventory/{character_name}", summary="Get Character Inventory", description="Retrieve processed inventory data for a specific character with normalized item properties.", @@ -1784,8 +2030,9 @@ async def get_character_inventory( query = """ SELECT - i.id, i.name, i.icon, i.object_class, i.value, i.burden, + i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden, i.has_id_data, i.timestamp, + i.current_wielded_location, i.container_id, i.items_capacity, cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus, r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement, e.material, e.imbue, e.item_set, e.tinks, e.workmanship, @@ -1812,216 +2059,7 @@ async def get_character_inventory( raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}") # Convert to structured format with enhanced translations - processed_items = [] - for item in items: - processed_item = dict(item) - - # Get comprehensive translations from original_json - if processed_item.get('original_json'): - original_json = processed_item['original_json'] - # Handle case where original_json might be stored as string - if isinstance(original_json, str): - try: - original_json = json.loads(original_json) - except (json.JSONDecodeError, TypeError): - original_json = {} - - if original_json: - # Extract properties and get translations - properties = extract_item_properties(original_json) - - # Add translated properties to the item - processed_item['translated_properties'] = properties - - # Add material name - use material directly if it's already a string - if processed_item.get('material'): - if isinstance(processed_item['material'], str): - processed_item['material_name'] = processed_item['material'] - else: - processed_item['material_name'] = translate_material_type(processed_item['material']) - - # Add object class translation - if processed_item.get('object_class'): - processed_item['object_class_name'] = translate_object_class(processed_item['object_class'], original_json) - - # Add skill translation - if processed_item.get('equip_skill'): - processed_item['equip_skill_name'] = translate_skill(processed_item['equip_skill']) - - # Flatten the structure - move translated properties to top level - if 'translated_properties' in processed_item: - translated_props = processed_item.pop('translated_properties') - - # Move spells to top level - if 'spells' in translated_props: - processed_item['spells'] = translated_props['spells'] - - # Move translated_ints to top level for enhanced tooltips - if 'translated_ints' in translated_props: - processed_item['enhanced_properties'] = translated_props['translated_ints'] - - # Add weapon damage information - if 'weapon_damage' in translated_props: - weapon_damage = translated_props['weapon_damage'] - if weapon_damage.get('damage_range'): - processed_item['damage_range'] = weapon_damage['damage_range'] - if weapon_damage.get('damage_type'): - processed_item['damage_type'] = weapon_damage['damage_type'] - if weapon_damage.get('min_damage'): - processed_item['min_damage'] = weapon_damage['min_damage'] - - # Add weapon speed information - if 'weapon_speed' in translated_props: - speed_info = translated_props['weapon_speed'] - if speed_info.get('speed_text'): - processed_item['speed_text'] = speed_info['speed_text'] - if speed_info.get('speed_value'): - processed_item['speed_value'] = speed_info['speed_value'] - - # Add mana and spellcraft information - if 'mana_info' in translated_props: - mana_info = translated_props['mana_info'] - if mana_info.get('mana_display'): - processed_item['mana_display'] = mana_info['mana_display'] - if mana_info.get('spellcraft'): - processed_item['spellcraft'] = mana_info['spellcraft'] - if mana_info.get('current_mana'): - processed_item['current_mana'] = mana_info['current_mana'] - if mana_info.get('max_mana'): - processed_item['max_mana'] = mana_info['max_mana'] - - # Add icon overlay/underlay information from translated properties - if 'translated_ints' in translated_props: - translated_ints = translated_props['translated_ints'] - - # Icon overlay - check for the proper property name - if 'IconOverlay_Decal_DID' in translated_ints and translated_ints['IconOverlay_Decal_DID'] > 0: - processed_item['icon_overlay_id'] = translated_ints['IconOverlay_Decal_DID'] - - # Icon underlay - check for the proper property name - if 'IconUnderlay_Decal_DID' in translated_ints and translated_ints['IconUnderlay_Decal_DID'] > 0: - processed_item['icon_underlay_id'] = translated_ints['IconUnderlay_Decal_DID'] - - # Add comprehensive combat stats - if 'combat' in translated_props: - combat_stats = translated_props['combat'] - if combat_stats.get('max_damage', -1) != -1: - processed_item['max_damage'] = combat_stats['max_damage'] - if combat_stats.get('armor_level', -1) != -1: - processed_item['armor_level'] = combat_stats['armor_level'] - if combat_stats.get('damage_bonus', -1.0) != -1.0: - processed_item['damage_bonus'] = combat_stats['damage_bonus'] - if combat_stats.get('attack_bonus', -1.0) != -1.0: - processed_item['attack_bonus'] = combat_stats['attack_bonus'] - # Add missing combat bonuses - if combat_stats.get('melee_defense_bonus', -1.0) != -1.0: - processed_item['melee_defense_bonus'] = combat_stats['melee_defense_bonus'] - if combat_stats.get('magic_defense_bonus', -1.0) != -1.0: - processed_item['magic_defense_bonus'] = combat_stats['magic_defense_bonus'] - if combat_stats.get('missile_defense_bonus', -1.0) != -1.0: - processed_item['missile_defense_bonus'] = combat_stats['missile_defense_bonus'] - if combat_stats.get('elemental_damage_vs_monsters', -1.0) != -1.0: - processed_item['elemental_damage_vs_monsters'] = combat_stats['elemental_damage_vs_monsters'] - if combat_stats.get('mana_conversion_bonus', -1.0) != -1.0: - processed_item['mana_conversion_bonus'] = combat_stats['mana_conversion_bonus'] - - # Add comprehensive requirements - if 'requirements' in translated_props: - requirements = translated_props['requirements'] - if requirements.get('wield_level', -1) != -1: - processed_item['wield_level'] = requirements['wield_level'] - if requirements.get('skill_level', -1) != -1: - processed_item['skill_level'] = requirements['skill_level'] - if requirements.get('lore_requirement', -1) != -1: - processed_item['lore_requirement'] = requirements['lore_requirement'] - if requirements.get('equip_skill'): - processed_item['equip_skill'] = requirements['equip_skill'] - - # Add comprehensive enhancements - if 'enhancements' in translated_props: - enhancements = translated_props['enhancements'] - if enhancements.get('material'): - processed_item['material'] = enhancements['material'] - - # Add material information from translations - if 'translations' in translated_props: - trans = translated_props['translations'] - if trans.get('material_name'): - processed_item['material_name'] = trans['material_name'] - if trans.get('material_id'): - processed_item['material_id'] = trans['material_id'] - if trans.get('item_type_name'): - processed_item['item_type_name'] = trans['item_type_name'] - - # Continue with other enhancements - if 'enhancements' in translated_props: - enhancements = translated_props['enhancements'] - if enhancements.get('imbue'): - processed_item['imbue'] = enhancements['imbue'] - if enhancements.get('tinks', -1) != -1: - processed_item['tinks'] = enhancements['tinks'] - if enhancements.get('workmanship', -1.0) != -1.0: - processed_item['workmanship'] = enhancements['workmanship'] - processed_item['workmanship_text'] = translate_workmanship(int(enhancements['workmanship'])) - if enhancements.get('item_set'): - processed_item['item_set'] = enhancements['item_set'] - # Add equipment set name translation - set_id = str(enhancements['item_set']).strip() - dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) - attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) - if set_id in attribute_set_info: - processed_item['item_set_name'] = attribute_set_info[set_id] - else: - processed_item['item_set_name'] = f"Set {set_id}" - - # Add comprehensive ratings (use gear totals as fallback for armor/clothing) - if 'ratings' in translated_props: - ratings = translated_props['ratings'] - - # Damage rating: use individual rating or gear total - if ratings.get('damage_rating', -1) != -1: - processed_item['damage_rating'] = ratings['damage_rating'] - elif ratings.get('gear_damage', -1) > 0: - processed_item['damage_rating'] = ratings['gear_damage'] - - # Crit rating - if ratings.get('crit_rating', -1) != -1: - processed_item['crit_rating'] = ratings['crit_rating'] - elif ratings.get('gear_crit', -1) > 0: - processed_item['crit_rating'] = ratings['gear_crit'] - - # Crit damage rating: use individual rating or gear total - if ratings.get('crit_damage_rating', -1) != -1: - processed_item['crit_damage_rating'] = ratings['crit_damage_rating'] - elif ratings.get('gear_crit_damage', -1) > 0: - processed_item['crit_damage_rating'] = ratings['gear_crit_damage'] - - # Heal boost rating: use individual rating or gear total - if ratings.get('heal_boost_rating', -1) != -1: - processed_item['heal_boost_rating'] = ratings['heal_boost_rating'] - elif ratings.get('gear_healing_boost', -1) > 0: - processed_item['heal_boost_rating'] = ratings['gear_healing_boost'] - - # Apply material prefix to item name if material exists - if processed_item.get('material_name') and processed_item.get('name'): - original_name = processed_item['name'] - material_name = processed_item['material_name'] - - # Don't add prefix if name already starts with the material - if not original_name.lower().startswith(material_name.lower()): - processed_item['name'] = f"{material_name} {original_name}" - processed_item['original_name'] = original_name # Preserve original for reference - - # Remove raw data from response (keep clean output) - processed_item.pop('int_values', None) - processed_item.pop('double_values', None) - processed_item.pop('string_values', None) - processed_item.pop('bool_values', None) - processed_item.pop('original_json', None) - - # Remove null values for cleaner response - processed_item = {k: v for k, v in processed_item.items() if v is not None} - processed_items.append(processed_item) + processed_items = [enrich_db_item(item) for item in items] return { "character_name": character_name, diff --git a/main.py b/main.py index 146e2a00..448e1691 100644 --- a/main.py +++ b/main.py @@ -2002,7 +2002,18 @@ async def ws_receive_snapshots( f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item", json=item ) - if resp.status_code >= 400: + if resp.status_code < 400: + # Use enriched item from inventory-service response for broadcast + resp_json = resp.json() + enriched_item = resp_json.get("item") + if enriched_item: + data = { + "type": "inventory_delta", + "action": action, + "character_name": char_name, + "item": enriched_item + } + else: logger.warning(f"Inventory service returned {resp.status_code} for delta {action}") # Broadcast delta to all browser clients diff --git a/static/script.js b/static/script.js index a8033863..e8477268 100644 --- a/static/script.js +++ b/static/script.js @@ -893,6 +893,131 @@ function updateStatsTimeRange(content, name, timeRange) { } // Show or create an inventory window for a character +/** + * Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot. + * Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... } + * Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} } + * Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... } + * + * MyWorldObject uses -1 as sentinel for "not set" on int/double properties. + */ +function normalizeInventoryItem(item) { + if (!item) return item; + if (item.name && item.item_id) return item; + + // MyWorldObject uses -1 as "not set" sentinel — filter those out + const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined; + + if (!item.item_id) item.item_id = item.Id; + if (!item.icon) item.icon = item.Icon; + if (!item.object_class) item.object_class = item.ObjectClass; + if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData; + + const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null; + const material = item.Material || null; + if (material) { + item.material = material; + item.material_name = material; + } + + // Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service + if (baseName) { + if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) { + item.name = material + ' ' + baseName; + } else { + item.name = baseName; + } + } + + const iv = item.IntValues || {}; + if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']); + if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']); + + // Container/equipment tracking + if (item.container_id === undefined) item.container_id = item.ContainerId || 0; + if (item.current_wielded_location === undefined) { + item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0; + } + if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']); + + const armor = v(item.ArmorLevel); + if (armor !== undefined) item.armor_level = armor; + + const maxDmg = v(item.MaxDamage); + if (maxDmg !== undefined) item.max_damage = maxDmg; + + const dmgBonus = v(item.DamageBonus); + if (dmgBonus !== undefined) item.damage_bonus = dmgBonus; + + const atkBonus = v(item.AttackBonus); + if (atkBonus !== undefined) item.attack_bonus = atkBonus; + + const elemDmg = v(item.ElementalDmgBonus); + if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg; + + const meleeD = v(item.MeleeDefenseBonus); + if (meleeD !== undefined) item.melee_defense_bonus = meleeD; + + const magicD = v(item.MagicDBonus); + if (magicD !== undefined) item.magic_defense_bonus = magicD; + + const missileD = v(item.MissileDBonus); + if (missileD !== undefined) item.missile_defense_bonus = missileD; + + const manaC = v(item.ManaCBonus); + if (manaC !== undefined) item.mana_conversion_bonus = manaC; + + const wieldLvl = v(item.WieldLevel); + if (wieldLvl !== undefined) item.wield_level = wieldLvl; + + const skillLvl = v(item.SkillLevel); + if (skillLvl !== undefined) item.skill_level = skillLvl; + + const loreLvl = v(item.LoreRequirement); + if (loreLvl !== undefined) item.lore_requirement = loreLvl; + + if (item.EquipSkill) item.equip_skill = item.EquipSkill; + if (item.Mastery) item.mastery = item.Mastery; + if (item.ItemSet) item.item_set = item.ItemSet; + if (item.Imbue) item.imbue = item.Imbue; + + const tinks = v(item.Tinks); + if (tinks !== undefined) item.tinks = tinks; + + const work = v(item.Workmanship); + if (work !== undefined) item.workmanship = work; + + const damR = v(item.DamRating); + if (damR !== undefined) item.damage_rating = damR; + + const critR = v(item.CritRating); + if (critR !== undefined) item.crit_rating = critR; + + const healR = v(item.HealBoostRating); + if (healR !== undefined) item.heal_boost_rating = healR; + + const vitalR = v(item.VitalityRating); + if (vitalR !== undefined) item.vitality_rating = vitalR; + + const critDmgR = v(item.CritDamRating); + if (critDmgR !== undefined) item.crit_damage_rating = critDmgR; + + const damResR = v(item.DamResistRating); + if (damResR !== undefined) item.damage_resist_rating = damResR; + + const critResR = v(item.CritResistRating); + if (critResR !== undefined) item.crit_resist_rating = critResR; + + const critDmgResR = v(item.CritDamResistRating); + if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR; + + if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) { + item.spells = item.Spells; + } + + return item; +} + /** * Create a single inventory slot DOM element from item data. * Used by both initial inventory load and live delta updates. @@ -900,7 +1025,7 @@ function updateStatsTimeRange(content, name, timeRange) { function createInventorySlot(item) { const slot = document.createElement('div'); slot.className = 'inventory-slot'; - slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0); + slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0); // Create layered icon container const iconContainer = document.createElement('div'); @@ -1020,9 +1145,83 @@ function createInventorySlot(item) { slot.addEventListener('mouseleave', hideInventoryTooltip); slot.appendChild(iconContainer); + + // Add stack count if > 1 + const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1; + if (stackCount > 1) { + const countEl = document.createElement('div'); + countEl.className = 'inventory-count'; + countEl.textContent = stackCount; + slot.appendChild(countEl); + } + return slot; } +/** + * Equipment slots mapping for the AC inventory layout. + * Grid matches the real AC "Equipment Slots Enabled" paperdoll view. + * + * Layout (6 cols × 6 rows): + * Col: 1 2 3 4 5 6 + * Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red) + * Row 2: Trinket — ChestArmor — — Cloak + * Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt) + * Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants) + * Row 5: — Hands — LowerLegArmor — — + * Row 6: Shield — — Feet Weapon Ammo + */ +const EQUIP_SLOTS = { + // Row 1: Necklace, Head, 3× Aetheria/Sigil + 32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear + 1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear + 268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne + 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo + 1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree + + // Row 2: Trinket, Chest Armor, Cloak + 67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne + 2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor + 512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor + 134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak + + // Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt + 65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft + 4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor + 1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor + 8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor + 131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight + 2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear + + // Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants + 262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft + 32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear + 16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor + 524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight + 4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear + + // Row 5: Feet + 256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear + + // Row 6: Shield, Weapon, Ammo + 2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield + 1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon + 4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon + 16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held + 33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded + 8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition +}; + +const SLOT_COLORS = {}; +// Purple: jewelry +[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple'); +// Blue: armor +[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue'); +// Teal: clothing/misc +[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal'); +// Dark blue: weapons/combat +[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue'); + /** * Handle live inventory delta updates from WebSocket. * Updates the inventory grid for a character if their inventory window is open. @@ -1030,35 +1229,260 @@ function createInventorySlot(item) { function updateInventoryLive(delta) { const name = delta.character_name; const win = inventoryWindows[name]; - if (!win) return; // No inventory window open for this character + if (!win || !win._inventoryState) { + return; + } - const grid = win.querySelector('.inventory-grid'); - if (!grid) return; + const state = win._inventoryState; + const getItemId = (d) => { + if (d.item) return d.item.item_id || d.item.Id || d.item.id; + return d.item_id; + }; + + const itemId = getItemId(delta); if (delta.action === 'remove') { - const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id)); - const existing = grid.querySelector(`[data-item-id="${itemId}"]`); - if (existing) existing.remove(); - } else if (delta.action === 'add') { - const newSlot = createInventorySlot(delta.item); - grid.appendChild(newSlot); - } else if (delta.action === 'update') { - const itemId = delta.item.Id || delta.item.id || delta.item.item_id; - const existing = grid.querySelector(`[data-item-id="${itemId}"]`); - if (existing) { - const newSlot = createInventorySlot(delta.item); - existing.replaceWith(newSlot); + state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId); + } else if (delta.action === 'add' || delta.action === 'update') { + normalizeInventoryItem(delta.item); + const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId); + if (existingIdx >= 0) { + state.items[existingIdx] = delta.item; } else { - const newSlot = createInventorySlot(delta.item); - grid.appendChild(newSlot); + state.items.push(delta.item); } } - // Update item count - const countEl = win.querySelector('.inventory-count'); - if (countEl) { - const slotCount = grid.querySelectorAll('.inventory-slot').length; - countEl.textContent = `${slotCount} items`; + renderInventoryState(state); +} + +function renderInventoryState(state) { + // 1. Clear equipment slots + state.slotMap.forEach((slotEl) => { + slotEl.innerHTML = ''; + const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; + slotEl.className = `inv-equip-slot empty ${colorClass}`; + delete slotEl.dataset.itemId; + }); + + // 2. Identify containers (object_class === 10) by item_id for sidebar + // These are packs/sacks/pouches/foci that appear in inventory as items + // but should ONLY show in the pack sidebar, not in the item grid. + const containers = []; // container objects (object_class=10) + const containerItemIds = new Set(); // item_ids of containers (to exclude from grid) + + state.items.forEach(item => { + if (item.object_class === 10) { + containers.push(item); + containerItemIds.add(item.item_id); + } + }); + + // 3. Separate equipped items from pack items, excluding containers from grid + let totalBurden = 0; + const packItems = new Map(); // container_id → [items] (non-container items only) + + // Determine the character body container_id: items with wielded_location > 0 + // share a container_id that is NOT 0 and NOT a pack's item_id. + // We treat non-wielded items from the body container as "main backpack" items. + let bodyContainerId = null; + state.items.forEach(item => { + if (item.current_wielded_location && item.current_wielded_location > 0) { + const cid = item.container_id; + if (cid && cid !== 0 && !containerItemIds.has(cid)) { + bodyContainerId = cid; + } + } + }); + + state.items.forEach(item => { + totalBurden += (item.burden || 0); + + // Skip container objects — they go in sidebar only + if (containerItemIds.has(item.item_id)) return; + + if (item.current_wielded_location && item.current_wielded_location > 0) { + const mask = item.current_wielded_location; + const isArmor = item.object_class === 2; + + // For armor (object_class=2): render in ALL matching slots (multi-slot display) + // For everything else (clothing, jewelry, weapons): place in first matching slot only + if (isArmor) { + Object.keys(EQUIP_SLOTS).forEach(m => { + const slotMask = parseInt(m); + if ((mask & slotMask) === slotMask) { + const slotDef = EQUIP_SLOTS[slotMask]; + const key = `${slotDef.row}-${slotDef.col}`; + if (state.slotMap.has(key)) { + const slotEl = state.slotMap.get(key); + if (!slotEl.dataset.itemId) { + slotEl.innerHTML = ''; + const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; + slotEl.className = `inv-equip-slot equipped ${colorClass}`; + slotEl.dataset.itemId = item.item_id; + slotEl.appendChild(createInventorySlot(item)); + } + } + } + }); + } else { + // Non-armor: find the first matching slot by exact mask key, then by bit overlap + let placed = false; + // Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly) + if (EQUIP_SLOTS[mask]) { + const slotDef = EQUIP_SLOTS[mask]; + const key = `${slotDef.row}-${slotDef.col}`; + if (state.slotMap.has(key)) { + const slotEl = state.slotMap.get(key); + if (!slotEl.dataset.itemId) { + slotEl.innerHTML = ''; + const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; + slotEl.className = `inv-equip-slot equipped ${colorClass}`; + slotEl.dataset.itemId = item.item_id; + slotEl.appendChild(createInventorySlot(item)); + placed = true; + } + } + } + // If no exact match, find first matching bit in EQUIP_SLOTS + if (!placed) { + for (const m of Object.keys(EQUIP_SLOTS)) { + const slotMask = parseInt(m); + if ((mask & slotMask) === slotMask) { + const slotDef = EQUIP_SLOTS[slotMask]; + const key = `${slotDef.row}-${slotDef.col}`; + if (state.slotMap.has(key)) { + const slotEl = state.slotMap.get(key); + if (!slotEl.dataset.itemId) { + slotEl.innerHTML = ''; + const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; + slotEl.className = `inv-equip-slot equipped ${colorClass}`; + slotEl.dataset.itemId = item.item_id; + slotEl.appendChild(createInventorySlot(item)); + placed = true; + break; + } + } + } + } + } + } + } else { + // Non-equipped, non-container → pack item. Group by container_id. + let cid = item.container_id || 0; + // Items on the character body (not wielded) → treat as main backpack (cid=0) + if (bodyContainerId !== null && cid === bodyContainerId) cid = 0; + if (!packItems.has(cid)) packItems.set(cid, []); + packItems.get(cid).push(item); + } + }); + + state.burdenLabel.textContent = 'Burden'; + state.burdenFill.style.height = '0%'; + + // 4. Sort containers for stable sidebar order (by unsigned item_id) + containers.sort((a, b) => { + const ua = a.item_id >>> 0; + const ub = b.item_id >>> 0; + return ua - ub; + }); + + // 5. Render packs in sidebar + state.packList.innerHTML = ''; + + // Helper: compute icon URL from raw icon id + const iconUrl = (iconRaw) => { + if (!iconRaw) return '/icons/06001080.png'; + const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + return `/icons/${hex}.png`; + }; + + // --- Main backpack (container_id === 0, non-containers) --- + const mainPackEl = document.createElement('div'); + mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`; + const mainPackImg = document.createElement('img'); + mainPackImg.src = '/icons/06001BB1.png'; + mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; }; + + const mainFillCont = document.createElement('div'); + mainFillCont.className = 'inv-pack-fill-container'; + const mainFill = document.createElement('div'); + mainFill.className = 'inv-pack-fill'; + + // Main backpack items = container_id 0, excluding container objects + const mainPackItems = packItems.get(0) || []; + const mainPct = Math.min(100, (mainPackItems.length / 102) * 100); + mainFill.style.height = `${mainPct}%`; + + mainFillCont.appendChild(mainFill); + mainPackEl.appendChild(mainPackImg); + mainPackEl.appendChild(mainFillCont); + + mainPackEl.onclick = () => { + state.activePack = null; + renderInventoryState(state); + }; + state.packList.appendChild(mainPackEl); + + // --- Sub-packs: each container object (object_class=10) --- + containers.forEach(container => { + const cid = container.item_id; // items inside this pack have container_id = this item_id + const packEl = document.createElement('div'); + packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`; + const packImg = document.createElement('img'); + // Use the container's actual icon from the API + packImg.src = iconUrl(container.icon); + packImg.onerror = function() { this.src = '/icons/06001080.png'; }; + + const fillCont = document.createElement('div'); + fillCont.className = 'inv-pack-fill-container'; + const fill = document.createElement('div'); + fill.className = 'inv-pack-fill'; + + const pItems = packItems.get(cid) || []; + const capacity = container.items_capacity || 24; // default pack capacity in AC + const pPct = Math.min(100, (pItems.length / capacity) * 100); + fill.style.height = `${pPct}%`; + + fillCont.appendChild(fill); + packEl.appendChild(packImg); + packEl.appendChild(fillCont); + + packEl.onclick = () => { + state.activePack = cid; + renderInventoryState(state); + }; + state.packList.appendChild(packEl); + }); + + // 6. Render item grid + state.itemGrid.innerHTML = ''; + let itemsToShow = []; + if (state.activePack === null) { + // Main backpack: non-container items with container_id === 0 + itemsToShow = mainPackItems; + state.contentsHeader.textContent = 'Contents of Backpack'; + } else { + // Sub-pack: items with matching container_id + itemsToShow = packItems.get(state.activePack) || []; + // Use the container's name for the header + const activeContainer = containers.find(c => c.item_id === state.activePack); + state.contentsHeader.textContent = activeContainer + ? `Contents of ${activeContainer.name}` + : 'Contents of Pack'; + } + + const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6); + for (let i = 0; i < numCells; i++) { + const cell = document.createElement('div'); + if (i < itemsToShow.length) { + cell.className = 'inv-item-slot occupied'; + const itemNode = createInventorySlot(itemsToShow[i]); + cell.appendChild(itemNode); + } else { + cell.className = 'inv-item-slot'; + } + state.itemGrid.appendChild(cell); } } @@ -1078,19 +1502,120 @@ function showInventoryWindow(name) { win.dataset.character = name; inventoryWindows[name] = win; - // Loading message const loading = document.createElement('div'); loading.className = 'inventory-loading'; loading.textContent = 'Loading inventory...'; content.appendChild(loading); - // Inventory content container const invContent = document.createElement('div'); invContent.className = 'inventory-content'; invContent.style.display = 'none'; content.appendChild(invContent); - // Fetch inventory data from main app (which will proxy to inventory service) + const topSection = document.createElement('div'); + topSection.className = 'inv-top-section'; + + const equipGrid = document.createElement('div'); + equipGrid.className = 'inv-equipment-grid'; + + const slotMap = new Map(); + const createdSlots = new Set(); + + Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => { + const key = `${slotDef.row}-${slotDef.col}`; + if (!createdSlots.has(key)) { + createdSlots.add(key); + const slotEl = document.createElement('div'); + const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue'; + slotEl.className = `inv-equip-slot empty ${colorClass}`; + slotEl.style.left = `${(slotDef.col - 1) * 44}px`; + slotEl.style.top = `${(slotDef.row - 1) * 44}px`; + slotEl.dataset.pos = key; + equipGrid.appendChild(slotEl); + slotMap.set(key, slotEl); + } + }); + + const sidebar = document.createElement('div'); + sidebar.className = 'inv-sidebar'; + + const burdenContainer = document.createElement('div'); + burdenContainer.className = 'inv-burden-bar'; + const burdenFill = document.createElement('div'); + burdenFill.className = 'inv-burden-fill'; + const burdenLabel = document.createElement('div'); + burdenLabel.className = 'inv-burden-label'; + burdenLabel.textContent = 'Burden'; + burdenContainer.appendChild(burdenLabel); + burdenContainer.appendChild(burdenFill); + sidebar.appendChild(burdenContainer); + + const packList = document.createElement('div'); + packList.className = 'inv-pack-list'; + sidebar.appendChild(packList); + + topSection.appendChild(equipGrid); + topSection.appendChild(sidebar); + + const bottomSection = document.createElement('div'); + bottomSection.className = 'inv-bottom-section'; + + const contentsHeader = document.createElement('div'); + contentsHeader.className = 'inv-contents-header'; + contentsHeader.textContent = 'Contents of Backpack'; + + const itemGrid = document.createElement('div'); + itemGrid.className = 'inv-item-grid'; + + bottomSection.appendChild(contentsHeader); + bottomSection.appendChild(itemGrid); + + invContent.appendChild(topSection); + invContent.appendChild(bottomSection); + + const resizeGrip = document.createElement('div'); + resizeGrip.className = 'inv-resize-grip'; + win.appendChild(resizeGrip); + + let resizing = false; + let startY, startH; + + resizeGrip.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + resizing = true; + startY = e.clientY; + startH = win.offsetHeight; + document.body.style.cursor = 'ns-resize'; + document.body.style.userSelect = 'none'; + }); + + document.addEventListener('mousemove', (e) => { + if (!resizing) return; + const newH = Math.max(400, startH + (e.clientY - startY)); + win.style.height = newH + 'px'; + }); + + document.addEventListener('mouseup', () => { + if (!resizing) return; + resizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }); + + win._inventoryState = { + items: [], + activePack: null, + slotMap: slotMap, + equipGrid: equipGrid, + itemGrid: itemGrid, + packList: packList, + burdenFill: burdenFill, + burdenLabel: burdenLabel, + contentsHeader: contentsHeader, + characterName: name + }; + fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -1098,24 +1623,12 @@ function showInventoryWindow(name) { }) .then(data => { loading.style.display = 'none'; - invContent.style.display = 'block'; - - // Create inventory grid - const grid = document.createElement('div'); - grid.className = 'inventory-grid'; - - // Render each item - data.items.forEach(item => { - grid.appendChild(createInventorySlot(item)); - }); - - invContent.appendChild(grid); - - // Add item count - const count = document.createElement('div'); - count.className = 'inventory-count'; - count.textContent = `${data.item_count} items`; - invContent.appendChild(count); + invContent.style.display = 'flex'; + + data.items.forEach(i => normalizeInventoryItem(i)); + win._inventoryState.items = data.items; + + renderInventoryState(win._inventoryState); }) .catch(err => { handleError('Inventory', err, true); diff --git a/static/style.css b/static/style.css index d958bc8b..ef0ae281 100644 --- a/static/style.css +++ b/static/style.css @@ -709,13 +709,15 @@ body.noselect, body.noselect * { border-color: var(--accent); } -/* ---------- inventory window styling ----------------------------- */ +/* ---------- inventory window styling (AC Layout) ----------------------------- */ .inventory-content { flex: 1; - padding: 15px; - background: var(--card); - color: var(--text); - overflow-y: auto; + display: flex; + flex-direction: column; + background: none; + color: var(--ac-text); + overflow: hidden; + padding: 8px; } .inventory-placeholder { @@ -733,15 +735,18 @@ body.noselect, body.noselect * { position: fixed; top: 100px; left: 400px; - width: 600px; - height: 500px; - background: var(--card); - border: 1px solid #555; - border-radius: 8px; + width: 390px; + height: 520px; + background: rgba(20, 20, 20, 0.92); + backdrop-filter: blur(2px); + border: 2px solid var(--ac-gold); + border-radius: 4px; display: flex; flex-direction: column; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); + box-shadow: inset 0 0 10px #000, 0 4px 15px rgba(0, 0, 0, 0.8); z-index: 1000; + font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; + overflow: hidden; } .inventory-loading { @@ -750,37 +755,229 @@ body.noselect, body.noselect * { justify-content: center; height: 100%; font-size: 1.1rem; - color: #888; + color: var(--ac-text-dim); } -/* Inventory grid layout - matches AC original */ -.inventory-grid { +.inv-top-section { + display: flex; + justify-content: space-between; + height: 264px; +} + +.inv-equipment-grid { + position: relative; + width: 308px; + height: 264px; +} + +.inv-equip-slot { + position: absolute; + width: 36px; + height: 36px; + background: var(--ac-medium-stone); + border-top: 2px solid #3d4b5f; + border-left: 2px solid #3d4b5f; + border-bottom: 2px solid #12181a; + border-right: 2px solid #12181a; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.inv-equip-slot.equipped { + border: 2px solid var(--ac-cyan); + box-shadow: 0 0 5px var(--ac-cyan), inset 0 0 5px var(--ac-cyan); +} + +.inv-equip-slot.empty::before { + content: ""; + display: block; + width: 28px; + height: 28px; + background-image: url('/icons/06000133.png'); + background-size: contain; + opacity: 0.15; + filter: grayscale(100%); +} + +.inv-equip-slot .inventory-slot { + width: 100%; + height: 100%; +} + +.inv-sidebar { + width: 60px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + overflow: visible; +} + +.inv-burden-bar { + width: 16px; + height: 40px; + background: #0a0a0a; + border: 1px solid var(--ac-border-light); + position: relative; + display: flex; + flex-direction: column-reverse; + margin-bottom: 2px; + margin-top: 12px; + flex-shrink: 0; +} + +.inv-burden-fill { + width: 100%; + background: var(--ac-green); + height: 0%; + transition: height 0.3s ease; +} + +.inv-burden-label { + position: absolute; + top: -18px; + width: 60px; + left: -22px; + text-align: center; + font-size: 11px; + color: var(--ac-gold); +} + +.inv-pack-list { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + align-items: center; + flex: 1; + min-height: 0; +} + +.inv-pack-icon { + width: 32px; + height: 32px; + position: relative; + cursor: pointer; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: center; + background: #000; + flex-shrink: 0; +} + +.inv-pack-icon.active { + border: 1px solid var(--ac-green); + box-shadow: 0 0 4px var(--ac-green); +} + +.inv-pack-icon.active::before { + content: "▶"; + position: absolute; + left: -14px; + top: 10px; + color: var(--ac-gold); + font-size: 12px; +} + +.inv-pack-fill-container { + position: absolute; + bottom: -6px; + left: -1px; + width: 36px; + height: 4px; + background: #000; + border: 1px solid #333; +} + +.inv-pack-fill { + height: 100%; + background: var(--ac-green); + width: 0%; +} + +.inv-pack-icon img { + width: 28px; + height: 28px; + object-fit: contain; + image-rendering: pixelated; +} + +.inv-bottom-section { + flex: 1; + display: flex; + flex-direction: column; + margin-top: 10px; + margin-right: 52px; + overflow: hidden; + min-height: 0; +} + +.inv-contents-header { + color: var(--ac-gold); + font-size: 14px; + margin-bottom: 4px; + text-align: center; + border-bottom: 1px solid var(--ac-border-light); + padding-bottom: 2px; +} + +.inv-item-grid { display: grid; - grid-template-columns: repeat(8, 36px); - gap: 0px; - padding: 8px; - background: - linear-gradient(90deg, #333 1px, transparent 1px), - linear-gradient(180deg, #333 1px, transparent 1px), - #111; - background-size: 36px 36px; - max-height: 450px; + grid-template-columns: repeat(6, 36px); + grid-auto-rows: 36px; + gap: 2px; + background: var(--ac-black); + padding: 4px; + border: 1px solid var(--ac-border-light); + flex: 1; overflow-y: auto; - border: 1px solid #444; + min-height: 0; + align-content: start; + justify-content: start; } -/* Individual inventory slots - no borders like AC original */ +.inv-item-grid::-webkit-scrollbar { + width: 12px; +} +.inv-item-grid::-webkit-scrollbar-track { + background: #0a0a0a; + border: 1px solid #333; +} +.inv-item-grid::-webkit-scrollbar-thumb { + background: #0022cc; + border-top: 2px solid var(--ac-gold); + border-bottom: 2px solid var(--ac-gold); +} + +.inv-item-slot { + width: 36px; + height: 36px; + background: #0a0a0a; + border: 1px solid #222; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.inv-item-slot.occupied { + background: linear-gradient(135deg, #3d007a 0%, #1a0033 100%); + border: 1px solid #4a148c; +} + +/* Base slot styling used by createInventorySlot */ .inventory-slot { width: 36px; height: 36px; background: transparent; border: none; - border-radius: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; - transition: background 0.1s ease; padding: 0; margin: 0; } @@ -794,14 +991,11 @@ body.noselect, body.noselect * { height: 36px; object-fit: contain; image-rendering: pixelated; - /* Improve icon appearance - make background match slot */ border: none; outline: none; - background: #1a1a1a; - border-radius: 2px; } -/* Icon compositing for overlays/underlays - matches AC original */ +/* Icon compositing */ .item-icon-composite { position: relative; width: 36px; @@ -827,24 +1021,13 @@ body.noselect, body.noselect * { margin: 0; } -.icon-underlay { - z-index: 1; -} +.icon-underlay { z-index: 1; } +.icon-base { z-index: 2; } +.icon-overlay { z-index: 3; } -.icon-base { - z-index: 2; -} - -.icon-overlay { - z-index: 3; -} - -/* Item count */ +/* Item count (hidden in new AC layout, kept for compatibility) */ .inventory-count { - text-align: center; - padding: 10px; - color: #888; - font-size: 0.9rem; + display: none; } /* Inventory tooltip */ @@ -1848,3 +2031,272 @@ table.ts-allegiance td:first-child { border-color: #af7a30; } + +/* ============================================== + Inventory Window Visual Fixes - AC Game Match + ============================================== */ + +.inventory-window, +.inventory-window * { + font-family: "Times New Roman", Times, serif !important; + text-shadow: 1px 1px 0 #000 !important; +} + +.inventory-window .chat-header { + background: #0e0c08 !important; + border-bottom: 1px solid #8a7a44 !important; + color: #d4af37 !important; + padding: 4px 6px !important; + box-shadow: none !important; + font-size: 11px !important; + font-weight: bold !important; + height: 22px !important; + box-sizing: border-box !important; + display: flex !important; + align-items: center !important; +} + +.inventory-window .window-content { + background: linear-gradient(180deg, #1a1814 0%, #0e0c0a 100%) !important; + border: 2px solid #8a7a44 !important; + padding: 4px !important; +} + +.inv-equipment-grid { + background: + radial-gradient(ellipse at 20% 50%, rgba(30, 28, 25, 0.6) 0%, transparent 70%), + radial-gradient(ellipse at 80% 30%, rgba(25, 23, 20, 0.4) 0%, transparent 60%), + radial-gradient(ellipse at 50% 80%, rgba(35, 30, 25, 0.5) 0%, transparent 50%), + linear-gradient(180deg, #0e0c0a 0%, #141210 50%, #0c0a08 100%) !important; +} + +.inv-equip-slot { + width: 36px !important; + height: 36px !important; + border-top: 1px solid #2a2a30 !important; + border-left: 1px solid #2a2a30 !important; + border-bottom: 1px solid #0a0a0e !important; + border-right: 1px solid #0a0a0e !important; + background: #14141a !important; +} + +.inv-equip-slot.equipped { + border: 1px solid #222 !important; + background: #14141a !important; + box-shadow: none !important; +} + +/* Equipment slot color categories - matching real AC + Real AC uses clearly visible colored borders AND tinted backgrounds per slot type */ +.inv-equip-slot.slot-purple { + border: 1px solid #8040a8 !important; + background: #2a1538 !important; +} +.inv-equip-slot.slot-blue { + border: 1px solid #3060b0 !important; + background: #141e38 !important; +} +.inv-equip-slot.slot-teal { + border: 1px solid #309898 !important; + background: #0e2828 !important; +} +.inv-equip-slot.slot-darkblue { + border: 1px solid #1e3060 !important; + background: #0e1428 !important; +} +/* Brighter tint when equipped (item present) */ +.inv-equip-slot.equipped.slot-purple { + border: 1px solid #9050b8 !important; + background: #341a44 !important; +} +.inv-equip-slot.equipped.slot-blue { + border: 1px solid #4070c0 !important; + background: #1a2844 !important; +} +.inv-equip-slot.equipped.slot-teal { + border: 1px solid #40a8a8 !important; + background: #143030 !important; +} +.inv-equip-slot.equipped.slot-darkblue { + border: 1px solid #283870 !important; + background: #141a30 !important; +} + +.inv-equip-slot.empty::before { + opacity: 0.15 !important; + filter: grayscale(100%) !important; +} + +.inv-item-grid { + background: #1a1208 !important; + gap: 2px !important; +} + +.inv-item-slot.occupied { + background: #442c1e !important; + border: 1px solid #5a3c28 !important; +} + +.inv-item-slot { + background: #2a1c14 !important; + border: 1px solid #3a2818 !important; +} + +.inv-contents-header { + font-size: 10px !important; + font-family: "Times New Roman", Times, serif !important; + color: #ffffff !important; + border-bottom: none !important; + text-align: center !important; + padding-bottom: 2px !important; + margin-bottom: 2px !important; + text-transform: none !important; + letter-spacing: 0 !important; +} + +.inv-sidebar { + width: 52px !important; + align-items: center !important; + overflow: visible !important; +} + +.inv-pack-icon { + width: 32px !important; + height: 32px !important; + border: 1px solid #1a1a1a !important; + margin-bottom: 2px !important; + overflow: visible !important; + margin-right: 8px !important; +} + +.inv-pack-icon img { + width: 28px !important; + height: 28px !important; +} + +.inv-pack-icon.active { + border: 1px solid #8a7a44 !important; + position: relative !important; + box-shadow: none !important; +} + +.inv-pack-icon.active::before { + content: '' !important; + position: absolute !important; + left: -8px !important; + top: 50% !important; + transform: translateY(-50%) !important; + width: 0 !important; + height: 0 !important; + border-top: 6px solid transparent !important; + border-bottom: 6px solid transparent !important; + border-left: 7px solid #d4af37 !important; + display: block !important; +} + +.inv-pack-fill-container { + position: absolute !important; + right: -6px !important; + top: 0 !important; + bottom: auto !important; + left: auto !important; + width: 4px !important; + height: 32px !important; + background: #000 !important; + border: 1px solid #333 !important; + display: flex !important; + flex-direction: column-reverse !important; +} + +.inv-pack-fill { + width: 100% !important; + background: #00ff00 !important; + transition: height 0.3s ease !important; +} + +.inv-item-grid::-webkit-scrollbar { + width: 14px; +} +.inv-item-grid::-webkit-scrollbar-track { + background: #0e0a04; + border: 1px solid #8a7a44; +} +.inv-item-grid::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #2244aa 0%, #1a3399 50%, #2244aa 100%); + border: 1px solid #8a7a44; +} +.inv-item-grid::-webkit-scrollbar-button:vertical:start:decrement, +.inv-item-grid::-webkit-scrollbar-button:vertical:end:increment { + background: #8a2020; + border: 1px solid #b89a30; + height: 14px; + display: block; +} + +.inv-burden-bar { + width: 14px !important; + height: 40px !important; + margin-top: 20px !important; +} + +.inv-burden-label { + position: absolute !important; + top: -20px !important; + width: 60px !important; + left: -22px !important; + text-align: center !important; + font-size: 9px !important; + color: #fff !important; + font-weight: normal !important; + line-height: 1.1 !important; +} + +.inventory-count { + display: block !important; + position: absolute; + top: 1px; + right: 1px; + bottom: auto; + left: auto; + font-size: 8px !important; + color: #fff !important; + background: #1a3399 !important; + padding: 0 2px !important; + line-height: 12px !important; + min-width: 8px !important; + text-align: center !important; + pointer-events: none; + z-index: 10; + text-shadow: none !important; +} + +.inventory-window { + border: 2px solid #8a7a44 !important; + background: #0e0c08 !important; + resize: none !important; +} + +/* Custom resize grip for inventory window */ +.inv-resize-grip { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; + z-index: 100; + background: transparent; + border-top: 1px solid #8a7a44; +} + +.inv-resize-grip::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 30px; + height: 2px; + border-top: 1px solid #5a4a24; + border-bottom: 1px solid #5a4a24; +}