diff --git a/inventory-service/main.py b/inventory-service/main.py index 051f0eee..ecf300be 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Dict, List, Optional, Any from datetime import datetime -from fastapi import FastAPI, HTTPException, Depends, Query +from fastapi import FastAPI, HTTPException, Depends, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from sse_starlette.sse import EventSourceResponse @@ -24,15 +24,65 @@ from database import ( ItemRatings, ItemSpells, ItemRawData, DATABASE_URL, create_indexes ) +# Import helpers to share enum mappings +import helpers + # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# FastAPI app +# Import suitbuilder router - after logger is defined +try: + from suitbuilder import router as suitbuilder_router, set_database_connection + SUITBUILDER_AVAILABLE = True + logger.info("Suitbuilder module imported successfully") +except ImportError as e: + logger.warning(f"Suitbuilder module not available: {e}") + SUITBUILDER_AVAILABLE = False + set_database_connection = None + +# FastAPI app with comprehensive OpenAPI documentation app = FastAPI( - title="Inventory Service", - description="Microservice for Asheron's Call item data processing and queries", - version="1.0.0" + title="Inventory Service API", + description=""" + ## Comprehensive Inventory Management API for Asheron's Call + + This service provides powerful search and data processing capabilities for game item inventories. + + ### Key Features + - **Item Search**: Advanced filtering across 40+ parameters including spells, stats, sets, and more + - **Character Management**: Multi-character inventory tracking and processing + - **Equipment Optimization**: Integration with suitbuilder for optimal equipment combinations + - **Real-time Processing**: Live inventory data ingestion and normalization + - **Comprehensive Filtering**: Support for armor, jewelry, weapons, clothing, and spell-based searches + + ### Main Endpoints + - `/search/items` - Advanced item search with extensive filtering options + - `/process-inventory` - Process and normalize raw inventory data + - `/inventory/{character}` - Get character-specific inventory data + - `/suitbuilder/search` - Equipment optimization and suit building + + ### Search Parameters + The search endpoint supports comprehensive filtering including: + - **Text Search**: Item names, descriptions, properties + - **Character Filtering**: Specific characters or cross-character search + - **Equipment Status**: Equipped, unequipped, or all items + - **Item Categories**: Armor, jewelry, weapons, clothing filters + - **Combat Properties**: Damage, armor, attack bonuses, ratings + - **Spell Filtering**: Specific spells, cantrips, legendary effects + - **Requirements**: Level, skill requirements + - **Enhancements**: Materials, workmanship, item sets, tinkering + - **Item State**: Bonded, attuned, condition, rarity + - **Sorting & Pagination**: Flexible result ordering and pagination + """, + version="1.0.0", + contact={ + "name": "Dereth Tracker", + "url": "https://github.com/your-repo/dereth-tracker", + }, + license_info={ + "name": "MIT", + }, ) # Configure CORS @@ -44,6 +94,11 @@ app.add_middleware( allow_headers=["*"], ) +# Include suitbuilder router +if SUITBUILDER_AVAILABLE: + app.include_router(suitbuilder_router, prefix="/suitbuilder", tags=["suitbuilder"]) + logger.info("Suitbuilder endpoints included at /suitbuilder") + # Database connection database = databases.Database(DATABASE_URL) engine = sa.create_engine(DATABASE_URL) @@ -188,6 +243,9 @@ def load_comprehensive_enums(): ENUM_MAPPINGS = load_comprehensive_enums() +# Share enum mappings with helpers module for suitbuilder +helpers.set_enum_mappings(ENUM_MAPPINGS) + # Pydantic models class InventoryItem(BaseModel): """Raw inventory item from plugin.""" @@ -204,12 +262,100 @@ class ProcessedItem(BaseModel): burden: int # Add other fields as needed +# Response Models for API Documentation +class ItemSearchResponse(BaseModel): + """Response model for item search endpoint.""" + items: List[Dict[str, Any]] + total_count: int + page: int + limit: int + has_next: bool + has_previous: bool + + class Config: + schema_extra = { + "example": { + "items": [ + { + "name": "Gold Celdon Girth", + "character_name": "Megamula XXXIII", + "armor_level": 320, + "crit_damage_rating": 2, + "set_name": "Soldier's", + "material_name": "Gold", + "equipped": False, + "spells": ["Legendary Strength", "Legendary Endurance"] + } + ], + "total_count": 1247, + "page": 1, + "limit": 200, + "has_next": True, + "has_previous": False + } + } + +class ProcessingStats(BaseModel): + """Response model for inventory processing results.""" + processed_count: int + error_count: int + character_name: str + timestamp: datetime + errors: Optional[List[str]] = None + + class Config: + schema_extra = { + "example": { + "processed_count": 186, + "error_count": 22, + "character_name": "Megamula XXXIII", + "timestamp": "2024-01-15T10:30:00Z", + "errors": ["Item ID 12345: SQL type error", "Item ID 67890: Missing required field"] + } + } + +class SetListResponse(BaseModel): + """Response model for equipment sets list.""" + sets: List[Dict[str, Any]] + + class Config: + schema_extra = { + "example": { + "sets": [ + {"set_name": "Soldier's", "item_count": 847, "set_id": 13}, + {"set_name": "Adept's", "item_count": 623, "set_id": 14}, + {"set_name": "Hearty", "item_count": 1205, "set_id": 42} + ] + } + } + +class HealthResponse(BaseModel): + """Response model for health check.""" + status: str + timestamp: datetime + database_connected: bool + version: str + + class Config: + schema_extra = { + "example": { + "status": "healthy", + "timestamp": "2024-01-15T10:30:00Z", + "database_connected": True, + "version": "1.0.0" + } + } + # Startup/shutdown events @app.on_event("startup") async def startup(): """Initialize database connection and create tables.""" await database.connect() + # Share database connection with suitbuilder + if SUITBUILDER_AVAILABLE and set_database_connection: + set_database_connection(database) + # Create tables if they don't exist Base.metadata.create_all(engine) @@ -1155,12 +1301,42 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: return properties # API endpoints -@app.post("/process-inventory") +@app.post("/process-inventory", + response_model=ProcessingStats, + summary="Process Raw Inventory Data", + description=""" + **Process and normalize raw inventory data from game plugins.** + + This endpoint receives raw inventory JSON data from game plugins and processes it + into normalized database tables with comprehensive enum translation and validation. + + ### Processing Steps: + 1. **Data Validation**: Validate incoming JSON structure and required fields + 2. **Enum Translation**: Convert game IDs to human-readable names using comprehensive enum database + 3. **Normalization**: Split item data into related tables (combat stats, spells, enhancements, etc.) + 4. **Error Handling**: Track and report any processing errors with detailed error messages + 5. **Statistics**: Return comprehensive processing statistics + + ### Database Schema: + - **items**: Core item properties (name, icon, value, etc.) + - **item_combat_stats**: Armor level, damage bonuses, attack ratings + - **item_enhancements**: Material, workmanship, item sets, tinkering + - **item_spells**: Spell names and categories + - **item_requirements**: Level and skill requirements + - **item_ratings**: All rating values (crit damage, damage resist, etc.) + - **item_raw_data**: Original JSON for complex queries + + ### Error Handling: + Returns detailed error information for items that fail to process, + including SQL type errors, missing fields, and validation failures. + """, + tags=["Data Processing"]) async def process_inventory(inventory: InventoryItem): - """Process raw inventory data and store in normalized format.""" + """Process raw inventory data and store in normalized database format.""" processed_count = 0 error_count = 0 + processing_errors = [] async with database.transaction(): # First, delete all existing items for this character from all related tables @@ -1345,20 +1521,25 @@ async def process_inventory(inventory: InventoryItem): processed_count += 1 except Exception as e: - logger.error(f"Error processing item {item_data.get('Id', 'unknown')}: {e}") + error_msg = f"Error processing item {item_data.get('Id', 'unknown')}: {e}" + logger.error(error_msg) + processing_errors.append(error_msg) error_count += 1 logger.info(f"Inventory processing complete for {inventory.character_name}: {processed_count} processed, {error_count} errors, {len(inventory.items)} total items received") - return { - "status": "completed", - "processed": processed_count, - "errors": error_count, - "total_received": len(inventory.items), - "character": inventory.character_name - } + return ProcessingStats( + processed_count=processed_count, + error_count=error_count, + character_name=inventory.character_name, + timestamp=inventory.timestamp, + errors=processing_errors if processing_errors else None + ) -@app.get("/inventory/{character_name}") +@app.get("/inventory/{character_name}", + summary="Get Character Inventory", + description="Retrieve processed inventory data for a specific character with normalized item properties.", + tags=["Character Data"]) async def get_character_inventory( character_name: str, limit: int = Query(1000, le=5000), @@ -1613,14 +1794,48 @@ async def get_character_inventory( "items": processed_items } -@app.get("/health") +@app.get("/health", + response_model=HealthResponse, + summary="Service Health Check", + description="Returns service health status, database connectivity, and version information.", + tags=["System"]) async def health_check(): - """Health check endpoint.""" - return {"status": "healthy", "service": "inventory-service"} + """Health check endpoint with comprehensive status information.""" + try: + # Test database connectivity + await database.fetch_one("SELECT 1") + db_connected = True + except: + db_connected = False + + return HealthResponse( + status="healthy" if db_connected else "degraded", + timestamp=datetime.now(), + database_connected=db_connected, + version="1.0.0" + ) -@app.get("/sets/list") +@app.get("/sets/list", + response_model=SetListResponse, + summary="List Equipment Sets", + description=""" + **Get all unique equipment set names with item counts.** + + Returns a list of all equipment sets found in the database along with + the number of items available for each set. Useful for understanding + available equipment options and set coverage. + + ### Common Set IDs: + - **13**: Soldier's (combat-focused armor) + - **14**: Adept's (magical armor) + - **42**: Hearty (health/vitality focused) + - **21**: Wise (mana-focused) + - **40**: Heroic Protector + - **41**: Heroic Destroyer + """, + tags=["Equipment Sets"]) async def list_equipment_sets(): - """Get all unique equipment set names from the database.""" + """Get all unique equipment set names with item counts from the database.""" try: # Get equipment set IDs (the numeric collection sets) query = """ @@ -1650,17 +1865,19 @@ async def list_equipment_sets(): "item_count": item_count }) - return { - "equipment_sets": equipment_sets, - "total_sets": len(equipment_sets) - } + return SetListResponse( + sets=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") +@app.get("/enum-info", + summary="Get Enum Information", + description="Get comprehensive information about available enum translations and database statistics.", + tags=["System"]) async def get_enum_info(): """Get information about available enum translations.""" if ENUM_MAPPINGS is None: @@ -1679,7 +1896,10 @@ async def get_enum_info(): "database_version": ENUM_MAPPINGS.get('full_database', {}).get('metadata', {}).get('version', 'unknown') } -@app.get("/translate/{enum_type}/{value}") +@app.get("/translate/{enum_type}/{value}", + summary="Translate Enum Value", + description="Translate a specific enum value to human-readable name using the comprehensive enum database.", + tags=["System"]) async def translate_enum_value(enum_type: str, value: int): """Translate a specific enum value to human-readable name.""" @@ -1753,28 +1973,63 @@ async def get_character_inventory_raw(character_name: str): # INVENTORY SEARCH API ENDPOINTS # =================================================================== -@app.get("/search/items") +@app.get("/search/items", + response_model=ItemSearchResponse, + summary="Advanced Item Search", + description=""" + **Comprehensive item search with extensive filtering capabilities.** + + This endpoint provides powerful search functionality across all character inventories + with support for 40+ filter parameters including text search, equipment categories, + combat properties, spells, requirements, and more. + + ### Example Searches: + - **Basic Text Search**: `?text=Celdon` - Find all items with "Celdon" in name + - **Character Specific**: `?character=Megamula%20XXXIII` - Items from one character + - **Multi-Character**: `?characters=Char1,Char2,Char3` - Items from specific characters + - **Set-Based**: `?item_set=13&min_crit_damage_rating=2` - Soldier's set with CD2+ + - **Spell Search**: `?legendary_cantrips=Legendary%20Strength,Legendary%20Endurance` + - **Category Filter**: `?armor_only=true&min_armor=300` - High-level armor only + - **Equipment Status**: `?equipment_status=unequipped` - Only inventory items + + ### Advanced Filtering: + - **Combat Properties**: Filter by damage, armor, attack bonuses, ratings + - **Spell Effects**: Search for specific cantrips, wards, or legendary effects + - **Item Categories**: Armor, jewelry, weapons, clothing with sub-categories + - **Enhancement Data**: Material types, workmanship, item sets, tinkering + - **Requirements**: Level, skill requirements for wielding + - **Item State**: Bonded, attuned, condition, rarity filters + + ### Response Format: + Returns paginated results with item details including translated properties, + combat stats, spell information, and character ownership data. + """, + tags=["Search"]) 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"), + text: str = Query(None, description="Search item names, descriptions, or properties", example="Celdon"), + character: str = Query(None, description="Limit search to specific character", example="Megamula XXXIII"), + characters: str = Query(None, description="Comma-separated list of character names", example="Char1,Char2,Char3"), 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)"), - slot_names: str = Query(None, description="Comma-separated list of slot names (e.g., Head,Chest,Ring)"), + equipment_status: str = Query(None, description="Filter by equipment status: 'equipped', 'unequipped', or omit for all", example="unequipped"), + equipment_slot: int = Query(None, description="Equipment slot mask (1=head, 2=neck, 4=chest, 8=abdomen, 16=upper_arms, 32=lower_arms, 64=hands, 128=upper_legs, 256=lower_legs, 512=feet, 1024=chest2, 2048=bracelet, 4096=ring)", example=4), + slot_names: str = Query(None, description="Comma-separated list of slot names", example="Head,Chest,Ring"), # 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"), + clothing_only: bool = Query(False, description="Show only clothing items (shirts/pants)"), + shirt_only: bool = Query(False, description="Show only shirt underclothes"), + pants_only: bool = Query(False, description="Show only pants underclothes"), + underwear_only: bool = Query(False, description="Show all underclothes (shirts and pants)"), # 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"), + has_spell: str = Query(None, description="Must have this specific spell (by name)", example="Legendary Strength"), + spell_contains: str = Query(None, description="Spell name contains this text", example="Legendary"), + legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names", example="Legendary Strength,Legendary Endurance"), # Combat properties min_damage: int = Query(None, description="Minimum damage"), @@ -1782,8 +2037,8 @@ async def search_items( 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_crit_damage_rating: int = Query(None, description="Minimum critical damage rating (0-2)", example=2), + min_damage_rating: int = Query(None, description="Minimum damage rating (0-3)", example=3), min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"), min_vitality_rating: int = Query(None, description="Minimum vitality rating"), min_damage_resist_rating: int = Query(None, description="Minimum damage resist rating"), @@ -1807,12 +2062,12 @@ async def search_items( 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"), + material: str = Query(None, description="Material type (partial match)", example="Gold"), + min_workmanship: float = Query(None, description="Minimum workmanship", example=9.5), 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_set: str = Query(None, description="Item set ID (single set)", example="13"), + item_sets: str = Query(None, description="Comma-separated list of item set IDs", example="13,14,42"), + min_tinks: int = Query(None, description="Minimum tinker count", example=3), # Item state bonded: bool = Query(None, description="Bonded status"), @@ -1836,6 +2091,15 @@ async def search_items( Search items across characters with comprehensive filtering options. """ try: + # Initialize underwear filter type + underwear_filter_type = None + if shirt_only: + underwear_filter_type = "shirts" + elif pants_only: + underwear_filter_type = "pants" + elif underwear_only: + underwear_filter_type = "all_underwear" + # Build base query with CTE for computed slot names query_parts = [""" WITH items_with_slots AS ( @@ -1873,9 +2137,15 @@ async def search_items( COALESCE((rd.int_values->>'376')::int, -1) ) as heal_boost_rating, COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating, - COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating, + GREATEST( + COALESCE((rd.int_values->>'308')::int, -1), + COALESCE((rd.int_values->>'371')::int, -1) + ) as damage_resist_rating, COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating, - COALESCE((rd.int_values->>'316')::int, -1) as crit_damage_resist_rating, + GREATEST( + COALESCE((rd.int_values->>'316')::int, -1), + COALESCE((rd.int_values->>'375')::int, -1) + ) as crit_damage_resist_rating, COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating, COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating, COALESCE((rd.int_values->>'342')::int, -1) as healing_rating, @@ -1895,6 +2165,7 @@ async def search_items( COALESCE(enh.tinks, -1) as tinks, COALESCE(enh.item_set, '') as item_set, rd.original_json, + COALESCE((rd.int_values->>'218103821')::int, 0) as coverage_mask, -- Compute slot_name in SQL CASE @@ -1979,6 +2250,47 @@ async def search_items( SELECT * FROM items_with_slots """] + # Apply underwear filtering by modifying the CTE query + if underwear_filter_type: + # Insert WHERE clause into the CTE before the closing parenthesis + cte_where_clause = None + if underwear_filter_type == "shirts": + # Shirts: ObjectClass 3 with UnderwearChest (8) but NOT pants patterns + # Exclude items that have both UnderwearUpperLegs (2) and UnderwearLowerLegs (4) which indicate pants + cte_where_clause = """WHERE i.object_class = 3 + AND ((rd.int_values->>'218103821')::int & 8) > 0 + AND NOT ((rd.int_values->>'218103821')::int & 6) = 6 + AND i.name NOT ILIKE '%robe%' + AND i.name NOT ILIKE '%cloak%' + AND i.name NOT ILIKE '%pallium%' + AND i.name NOT ILIKE '%armet%' + AND i.name NOT ILIKE '%pants%' + AND i.name NOT ILIKE '%breeches%'""" + elif underwear_filter_type == "pants": + # Pants: ObjectClass 3 with UnderwearUpperLegs (2) - covers both pants and breeches + # Include both full pants (2&4) and breeches (2 only) + cte_where_clause = """WHERE i.object_class = 3 + AND ((rd.int_values->>'218103821')::int & 2) = 2 + AND i.name NOT ILIKE '%robe%' + AND i.name NOT ILIKE '%cloak%' + AND i.name NOT ILIKE '%pallium%' + AND i.name NOT ILIKE '%armet%'""" + elif underwear_filter_type == "all_underwear": + # All underwear: ObjectClass 3 with any underwear bits (2,4,8,16) + cte_where_clause = """WHERE i.object_class = 3 + AND ((rd.int_values->>'218103821')::int & 30) > 0 + AND i.name NOT ILIKE '%robe%' + AND i.name NOT ILIKE '%cloak%' + AND i.name NOT ILIKE '%pallium%' + AND i.name NOT ILIKE '%armet%'""" + + if cte_where_clause: + # Insert the WHERE clause before the closing parenthesis of the CTE + query_parts[0] = query_parts[0].replace( + "LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n )", + f"LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n {cte_where_clause}\n )" + ) + conditions = [] params = {} @@ -2024,14 +2336,24 @@ async def search_items( # Item category filtering if armor_only: - # Armor: ObjectClass 2 (Clothing) or 3 (Armor) with armor_level > 0 - conditions.append("(object_class IN (2, 3) AND COALESCE(armor_level, 0) > 0)") + # Armor: ObjectClass 2 (Armor) with armor_level > 0 + conditions.append("(object_class = 2 AND COALESCE(armor_level, 0) > 0)") elif jewelry_only: # Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets conditions.append("object_class = 4") elif weapon_only: # Weapons: ObjectClass 6 (MeleeWeapon), 7 (MissileWeapon), 8 (Caster) with max_damage > 0 conditions.append("(object_class IN (6, 7, 8) AND COALESCE(max_damage, 0) > 0)") + elif clothing_only: + # Clothing: ObjectClass 3 (Clothing) - shirts and pants only, exclude cloaks and robes + # Focus on underclothes: shirts, pants, breeches, etc. + conditions.append("""(object_class = 3 AND + name NOT ILIKE '%cloak%' AND + name NOT ILIKE '%robe%' AND + name NOT ILIKE '%pallium%' AND + name NOT ILIKE '%armet%' AND + (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))""") + # Underwear filtering is handled in the CTE modification above # Spell filtering - need to join with item_spells and use spell database spell_join_added = False @@ -2350,7 +2672,8 @@ async def search_items( "crit_damage_rating": "crit_damage_rating", "heal_boost_rating": "heal_boost_rating", "vitality_rating": "vitality_rating", - "damage_resist_rating": "damage_resist_rating" + "damage_resist_rating": "damage_resist_rating", + "crit_damage_resist_rating": "crit_damage_resist_rating" } sort_field = sort_mapping.get(sort_by, "name") sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" @@ -2396,9 +2719,15 @@ async def search_items( COALESCE((rd.int_values->>'376')::int, -1) ) as heal_boost_rating, COALESCE((rd.int_values->>'379')::int, -1) as vitality_rating, - COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating, + GREATEST( + COALESCE((rd.int_values->>'308')::int, -1), + COALESCE((rd.int_values->>'371')::int, -1) + ) as damage_resist_rating, COALESCE((rd.int_values->>'315')::int, -1) as crit_resist_rating, - COALESCE((rd.int_values->>'316')::int, -1) as crit_damage_resist_rating, + GREATEST( + COALESCE((rd.int_values->>'316')::int, -1), + COALESCE((rd.int_values->>'375')::int, -1) + ) as crit_damage_resist_rating, COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating, COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating, COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating, @@ -2561,8 +2890,11 @@ async def search_items( item['coverage'] = ', '.join(coverage_parts) else: item['coverage'] = f"Coverage_{coverage_value}" + # Add raw coverage mask for armor reduction system + item['coverage_mask'] = coverage_value else: item['coverage'] = None + item['coverage_mask'] = 0 # Add sophisticated equipment slot translation using Mag-SuitBuilder logic # Use both EquipableSlots_Decal and Coverage for armor reduction @@ -2657,20 +2989,14 @@ async def search_items( 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) - } - } + return ItemSearchResponse( + items=items, + total_count=total_count, + page=page, + limit=limit, + has_next=page * limit < total_count, + has_previous=page > 1 + ) except Exception as e: logger.error(f"Search error: {e}", exc_info=True) @@ -2832,7 +3158,10 @@ async def find_equipment_upgrades( raise HTTPException(status_code=500, detail=f"Equipment upgrades search failed: {str(e)}") -@app.get("/characters/list") +@app.get("/characters/list", + summary="List Characters", + description="Get a list of all characters that have inventory data in the database.", + tags=["Character Data"]) async def list_inventory_characters(): """List all characters that have inventory data.""" try: @@ -3495,6 +3824,7 @@ async def test_simple_search(characters: str = Query(..., description="Comma-sep @app.get("/optimize/suits/stream") async def stream_optimize_suits( + request: Request, # Add request to detect client disconnection characters: str = Query(..., description="Comma-separated character names"), primary_set: Optional[int] = Query(None, description="Primary set ID requirement"), secondary_set: Optional[int] = Query(None, description="Secondary set ID requirement"), @@ -3590,6 +3920,15 @@ async def stream_optimize_suits( logger.info(f"Generated {len(armor_combinations)} armor combinations") for i, armor_combo in enumerate(armor_combinations): + # Check for client disconnection + if await request.is_disconnected(): + logger.info("Client disconnected, stopping search") + yield { + "event": "cancelled", + "data": json.dumps({"message": "Search cancelled by client"}) + } + return + # Check time limit if time.time() - start_time > limit_config["time_limit"]: yield { @@ -3630,6 +3969,15 @@ async def stream_optimize_suits( logger.info(f"Combination {i}: no scored suits returned") else: logger.info(f"Combination {i}: no complete suit generated") + + # Also check for disconnection every 10 iterations + if i % 10 == 0 and await request.is_disconnected(): + logger.info("Client disconnected during search") + yield { + "event": "cancelled", + "data": json.dumps({"message": "Search cancelled by client"}) + } + return # Final status yield { @@ -3804,6 +4152,15 @@ def determine_item_slots(item): coverage = item.get("coverage_mask", item.get("coverage", 0)) item_name = item["name"].lower() + # Name-based detection for clothing items first (for ObjectClass 3) + if item["object_class"] == 3: + if any(word in item_name for word in ["pants", "breeches", "baggy"]): + slots.append("Pants") + return slots + elif any(word in item_name for word in ["shirt", "tunic"]): + slots.append("Shirt") + return slots + # Use coverage mask if available if coverage and coverage > 0: slots.extend(decode_coverage_to_slots(coverage, item["object_class"], item["name"])) @@ -3844,6 +4201,16 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): # Only check for clothing patterns if this is actually a clothing item (ObjectClass 3) if object_class == 3: + # Check coverage mask patterns for underclothes first + # Shirts: UnderwearChest (8) OR UnderwearAbdomen (16) = mask & 24 > 0 + if coverage_mask & 24: # 8 + 16 = 24 + slots.append("Shirt") + return slots + + # Pants: UnderwearUpperLegs (2) AND UnderwearLowerLegs (4) = mask & 6 = 6 + if (coverage_mask & 6) == 6: # Both bits 2 and 4 must be set + slots.append("Pants") + return slots # Check for clothing patterns based on actual inventory data # Specific coverage patterns for ObjectClass 3 clothing items: @@ -3865,8 +4232,12 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): slots.append("Shirt") return slots - # Pants = UnderwearUpperLegs (2) + UnderwearLowerLegs (4) = 6 - if coverage_mask & 2 and coverage_mask & 4: # UnderwearUpperLegs + UnderwearLowerLegs + # Pants = UnderwearUpperLegs (2) + UnderwearLowerLegs (4) + UnderwearAbdomen (16) = 22 + if coverage_mask & 2 and coverage_mask & 4 and coverage_mask & 16: # Full pants pattern + slots.append("Pants") + return slots + # Also check for simple pants pattern without abdomen + elif coverage_mask & 2 and coverage_mask & 4: # UnderwearUpperLegs + UnderwearLowerLegs slots.append("Pants") return slots diff --git a/main.py b/main.py index 6cd4df0c..23570edd 100644 --- a/main.py +++ b/main.py @@ -1985,16 +1985,16 @@ async def ws_receive_snapshots( ew = float(ew) z = float(z) - # Round coordinates for comparison (0.01 tolerance) - ns_rounded = round(ns, 2) - ew_rounded = round(ew, 2) + # Round coordinates for comparison (0.1 tolerance to match DB constraint) + ns_rounded = round(ns, 1) + ew_rounded = round(ew, 1) # Check if portal exists at these coordinates existing_portal = await database.fetch_one( """ SELECT id FROM portals - WHERE ROUND(ns::numeric, 2) = :ns_rounded - AND ROUND(ew::numeric, 2) = :ew_rounded + WHERE ROUND(ns::numeric, 1) = :ns_rounded + AND ROUND(ew::numeric, 1) = :ew_rounded LIMIT 1 """, { @@ -2004,26 +2004,48 @@ async def ws_receive_snapshots( ) if not existing_portal: - # Store new portal in database - await database.execute( - portals.insert().values( - portal_name=portal_name, - ns=ns, - ew=ew, - z=z, - discovered_at=timestamp, - discovered_by=character_name + # Store new portal in database with ON CONFLICT handling + # This prevents race conditions and duplicate key errors + try: + await database.execute( + portals.insert().values( + portal_name=portal_name, + ns=ns, + ew=ew, + z=z, + discovered_at=timestamp, + discovered_by=character_name + ) ) - ) - logger.info(f"New portal discovered: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}") + logger.info(f"New portal discovered: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}") + except Exception as insert_error: + # If insert fails due to duplicate, update the existing portal + if "duplicate key" in str(insert_error).lower(): + await database.execute( + """ + UPDATE portals + SET discovered_at = :timestamp, discovered_by = :character_name + WHERE ROUND(ns::numeric, 1) = :ns_rounded + AND ROUND(ew::numeric, 1) = :ew_rounded + """, + { + "timestamp": timestamp, + "character_name": character_name, + "ns_rounded": ns_rounded, + "ew_rounded": ew_rounded + } + ) + logger.debug(f"Portal already exists (race condition), updated: {portal_name} at {ns_rounded}, {ew_rounded}") + else: + raise else: # Update timestamp for existing portal to keep it alive await database.execute( """ - UPDATE portals + UPDATE portals SET discovered_at = :timestamp, discovered_by = :character_name - WHERE ROUND(ns::numeric, 2) = :ns_rounded - AND ROUND(ew::numeric, 2) = :ew_rounded + WHERE ROUND(ns::numeric, 1) = :ns_rounded + AND ROUND(ew::numeric, 1) = :ew_rounded """, { "timestamp": timestamp, diff --git a/static/inventory.html b/static/inventory.html index cbc03c09..d96f7c0e 100644 --- a/static/inventory.html +++ b/static/inventory.html @@ -90,53 +90,89 @@ border: 1px solid #ccc; } + /* Filter Section Styling */ + .filter-card { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 8px; + margin-bottom: 8px; + } + + .filter-card-header { + font-weight: bold; + font-size: 11px; + color: #495057; + margin-bottom: 6px; + border-bottom: 1px solid #dee2e6; + padding-bottom: 2px; + } + .filter-row { display: flex; - gap: 10px; - margin-bottom: 5px; + gap: 8px; + margin-bottom: 6px; align-items: center; + flex-wrap: wrap; } .filter-group { display: flex; align-items: center; - gap: 3px; + gap: 4px; + min-width: 0; } .filter-group label { - font-weight: bold; - font-size: 10px; - color: #000; + font-weight: 600; + font-size: 11px; + color: #343a40; + min-width: 60px; + text-align: right; + } + + .filter-group-wide label { + min-width: 80px; } input[type="text"], input[type="number"], select { - border: 1px solid #999; - padding: 1px 3px; + border: 1px solid #ced4da; + border-radius: 3px; + padding: 4px 6px; font-size: 11px; - height: 18px; + height: 24px; + background: white; + } + + input[type="text"]:focus, + input[type="number"]:focus, + select:focus { + outline: none; + border-color: #80bdff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } input[type="text"] { - width: 120px; + width: 140px; } input[type="number"] { - width: 40px; + width: 50px; } select { - width: 100px; + width: 110px; } - .filter-section { - display: flex; - align-items: flex-start; - gap: 5px; - margin-bottom: 3px; + .range-separator { + color: #6c757d; + font-weight: bold; + margin: 0 4px; } + .section-label { font-weight: bold; font-size: 10px; @@ -145,11 +181,18 @@ color: #000; } + .checkbox-sections-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + .checkbox-container { display: flex; flex-wrap: wrap; - gap: 10px; - flex: 1; + gap: 4px; + max-height: 150px; + overflow-y: auto; } .checkbox-item { @@ -157,6 +200,8 @@ align-items: center; font-size: 9px; white-space: nowrap; + width: calc(50% - 2px); + min-width: 80px; } .checkbox-item input[type="checkbox"] { @@ -180,8 +225,9 @@ .search-actions { display: flex; - gap: 5px; - margin-top: 3px; + gap: 10px; + margin-top: 15px; + justify-content: flex-start; } .btn { @@ -429,32 +475,52 @@