diff --git a/inventory-service/main.py b/inventory-service/main.py index ecf300be..051f0eee 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, Request +from fastapi import FastAPI, HTTPException, Depends, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from sse_starlette.sse import EventSourceResponse @@ -24,65 +24,15 @@ 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__) -# 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 +# FastAPI app app = FastAPI( - 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", - }, + title="Inventory Service", + description="Microservice for Asheron's Call item data processing and queries", + version="1.0.0" ) # Configure CORS @@ -94,11 +44,6 @@ 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) @@ -243,9 +188,6 @@ 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.""" @@ -262,100 +204,12 @@ 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) @@ -1301,42 +1155,12 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: return properties # API endpoints -@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"]) +@app.post("/process-inventory") async def process_inventory(inventory: InventoryItem): - """Process raw inventory data and store in normalized database format.""" + """Process raw inventory data and store in normalized 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 @@ -1521,25 +1345,20 @@ async def process_inventory(inventory: InventoryItem): processed_count += 1 except Exception as e: - error_msg = f"Error processing item {item_data.get('Id', 'unknown')}: {e}" - logger.error(error_msg) - processing_errors.append(error_msg) + logger.error(f"Error processing item {item_data.get('Id', 'unknown')}: {e}") 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 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 - ) + return { + "status": "completed", + "processed": processed_count, + "errors": error_count, + "total_received": len(inventory.items), + "character": 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"]) +@app.get("/inventory/{character_name}") async def get_character_inventory( character_name: str, limit: int = Query(1000, le=5000), @@ -1794,48 +1613,14 @@ async def get_character_inventory( "items": processed_items } -@app.get("/health", - response_model=HealthResponse, - summary="Service Health Check", - description="Returns service health status, database connectivity, and version information.", - tags=["System"]) +@app.get("/health") async def health_check(): - """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" - ) + """Health check endpoint.""" + return {"status": "healthy", "service": "inventory-service"} -@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"]) +@app.get("/sets/list") async def list_equipment_sets(): - """Get all unique equipment set names with item counts from the database.""" + """Get all unique equipment set names from the database.""" try: # Get equipment set IDs (the numeric collection sets) query = """ @@ -1865,19 +1650,17 @@ async def list_equipment_sets(): "item_count": item_count }) - return SetListResponse( - sets=equipment_sets - ) + return { + "equipment_sets": equipment_sets, + "total_sets": len(equipment_sets) + } except Exception as e: logger.error(f"Failed to list equipment sets: {e}") raise HTTPException(status_code=500, detail="Failed to list equipment sets") -@app.get("/enum-info", - summary="Get Enum Information", - description="Get comprehensive information about available enum translations and database statistics.", - tags=["System"]) +@app.get("/enum-info") async def get_enum_info(): """Get information about available enum translations.""" if ENUM_MAPPINGS is None: @@ -1896,10 +1679,7 @@ async def get_enum_info(): "database_version": ENUM_MAPPINGS.get('full_database', {}).get('metadata', {}).get('version', 'unknown') } -@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"]) +@app.get("/translate/{enum_type}/{value}") async def translate_enum_value(enum_type: str, value: int): """Translate a specific enum value to human-readable name.""" @@ -1973,63 +1753,28 @@ async def get_character_inventory_raw(character_name: str): # INVENTORY SEARCH API ENDPOINTS # =================================================================== -@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"]) +@app.get("/search/items") async def search_items( # Text search - 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"), + text: str = Query(None, description="Search item names, descriptions, or properties"), + character: str = Query(None, description="Limit search to specific character"), + characters: str = Query(None, description="Comma-separated list of character names"), include_all_characters: bool = Query(False, description="Search across all characters"), # Equipment filtering - equipment_status: str = Query(None, description="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"), + equipment_status: str = Query(None, description="equipped, unequipped, or all"), + equipment_slot: int = Query(None, description="Equipment slot mask (e.g., 1=head, 512=chest)"), + slot_names: str = Query(None, description="Comma-separated list of slot names (e.g., Head,Chest,Ring)"), # Item category filtering armor_only: bool = Query(False, description="Show only armor items"), 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)", 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"), + has_spell: str = Query(None, description="Must have this specific spell (by name)"), + spell_contains: str = Query(None, description="Spell name contains this text"), + legendary_cantrips: str = Query(None, description="Comma-separated list of legendary cantrip names"), # Combat properties min_damage: int = Query(None, description="Minimum damage"), @@ -2037,8 +1782,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 (0-2)", example=2), - min_damage_rating: int = Query(None, description="Minimum damage rating (0-3)", example=3), + min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating"), + min_damage_rating: int = Query(None, description="Minimum damage rating"), min_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"), min_vitality_rating: int = Query(None, description="Minimum vitality rating"), min_damage_resist_rating: int = Query(None, description="Minimum damage resist rating"), @@ -2062,12 +1807,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)", example="Gold"), - min_workmanship: float = Query(None, description="Minimum workmanship", example=9.5), + material: str = Query(None, description="Material type (partial match)"), + min_workmanship: float = Query(None, description="Minimum workmanship"), has_imbue: bool = Query(None, description="Has imbue effects"), - item_set: str = Query(None, description="Item set ID (single set)", 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_set: str = Query(None, description="Item set ID (single set)"), + item_sets: str = Query(None, description="Comma-separated list of item set IDs"), + min_tinks: int = Query(None, description="Minimum tinker count"), # Item state bonded: bool = Query(None, description="Bonded status"), @@ -2091,15 +1836,6 @@ 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 ( @@ -2137,15 +1873,9 @@ 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, - GREATEST( - COALESCE((rd.int_values->>'308')::int, -1), - COALESCE((rd.int_values->>'371')::int, -1) - ) as damage_resist_rating, + COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating, COALESCE((rd.int_values->>'315')::int, -1) as crit_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->>'316')::int, -1) as crit_damage_resist_rating, COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating, COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating, COALESCE((rd.int_values->>'342')::int, -1) as healing_rating, @@ -2165,7 +1895,6 @@ 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 @@ -2250,47 +1979,6 @@ 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 = {} @@ -2336,24 +2024,14 @@ async def search_items( # Item category filtering if armor_only: - # Armor: ObjectClass 2 (Armor) with armor_level > 0 - conditions.append("(object_class = 2 AND COALESCE(armor_level, 0) > 0)") + # Armor: ObjectClass 2 (Clothing) or 3 (Armor) with armor_level > 0 + conditions.append("(object_class IN (2, 3) AND COALESCE(armor_level, 0) > 0)") elif jewelry_only: # Jewelry: ObjectClass 4 (Jewelry) - rings, bracelets, necklaces, amulets conditions.append("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 @@ -2672,8 +2350,7 @@ 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", - "crit_damage_resist_rating": "crit_damage_resist_rating" + "damage_resist_rating": "damage_resist_rating" } sort_field = sort_mapping.get(sort_by, "name") sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" @@ -2719,15 +2396,9 @@ 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, - GREATEST( - COALESCE((rd.int_values->>'308')::int, -1), - COALESCE((rd.int_values->>'371')::int, -1) - ) as damage_resist_rating, + COALESCE((rd.int_values->>'308')::int, -1) as damage_resist_rating, COALESCE((rd.int_values->>'315')::int, -1) as crit_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->>'316')::int, -1) as crit_damage_resist_rating, COALESCE((rd.int_values->>'317')::int, -1) as healing_resist_rating, COALESCE((rd.int_values->>'331')::int, -1) as nether_resist_rating, COALESCE((rd.int_values->>'350')::int, -1) as dot_resist_rating, @@ -2890,11 +2561,8 @@ 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 @@ -2989,14 +2657,20 @@ async def search_items( items.append(item) - return ItemSearchResponse( - items=items, - total_count=total_count, - page=page, - limit=limit, - has_next=page * limit < total_count, - has_previous=page > 1 - ) + return { + "items": items, + "total_count": total_count, + "page": page, + "limit": limit, + "total_pages": (total_count + limit - 1) // limit, + "search_criteria": { + "text": text, + "character": character, + "include_all_characters": include_all_characters, + "equipment_status": equipment_status, + "filters_applied": len(conditions) + } + } except Exception as e: logger.error(f"Search error: {e}", exc_info=True) @@ -3158,10 +2832,7 @@ async def find_equipment_upgrades( raise HTTPException(status_code=500, detail=f"Equipment upgrades search failed: {str(e)}") -@app.get("/characters/list", - summary="List Characters", - description="Get a list of all characters that have inventory data in the database.", - tags=["Character Data"]) +@app.get("/characters/list") async def list_inventory_characters(): """List all characters that have inventory data.""" try: @@ -3824,7 +3495,6 @@ 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"), @@ -3920,15 +3590,6 @@ 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 { @@ -3969,15 +3630,6 @@ 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 { @@ -4152,15 +3804,6 @@ 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"])) @@ -4201,16 +3844,6 @@ 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: @@ -4232,12 +3865,8 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): slots.append("Shirt") return slots - # 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 + # Pants = UnderwearUpperLegs (2) + UnderwearLowerLegs (4) = 6 + if coverage_mask & 2 and coverage_mask & 4: # UnderwearUpperLegs + UnderwearLowerLegs slots.append("Pants") return slots diff --git a/main.py b/main.py index 23570edd..6cd4df0c 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.1 tolerance to match DB constraint) - ns_rounded = round(ns, 1) - ew_rounded = round(ew, 1) + # Round coordinates for comparison (0.01 tolerance) + ns_rounded = round(ns, 2) + ew_rounded = round(ew, 2) # Check if portal exists at these coordinates existing_portal = await database.fetch_one( """ SELECT id FROM portals - WHERE ROUND(ns::numeric, 1) = :ns_rounded - AND ROUND(ew::numeric, 1) = :ew_rounded + WHERE ROUND(ns::numeric, 2) = :ns_rounded + AND ROUND(ew::numeric, 2) = :ew_rounded LIMIT 1 """, { @@ -2004,48 +2004,26 @@ async def ws_receive_snapshots( ) if not existing_portal: - # 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 - ) + # 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 ) - 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 + ) + logger.info(f"New portal discovered: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}") 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, 1) = :ns_rounded - AND ROUND(ew::numeric, 1) = :ew_rounded + WHERE ROUND(ns::numeric, 2) = :ns_rounded + AND ROUND(ew::numeric, 2) = :ew_rounded """, { "timestamp": timestamp, diff --git a/static/inventory.html b/static/inventory.html index d96f7c0e..cbc03c09 100644 --- a/static/inventory.html +++ b/static/inventory.html @@ -90,89 +90,53 @@ 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: 8px; - margin-bottom: 6px; + gap: 10px; + margin-bottom: 5px; align-items: center; - flex-wrap: wrap; } .filter-group { display: flex; align-items: center; - gap: 4px; - min-width: 0; + gap: 3px; } .filter-group label { - font-weight: 600; - font-size: 11px; - color: #343a40; - min-width: 60px; - text-align: right; - } - - .filter-group-wide label { - min-width: 80px; + font-weight: bold; + font-size: 10px; + color: #000; } input[type="text"], input[type="number"], select { - border: 1px solid #ced4da; - border-radius: 3px; - padding: 4px 6px; + border: 1px solid #999; + padding: 1px 3px; font-size: 11px; - 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); + height: 18px; } input[type="text"] { - width: 140px; + width: 120px; } input[type="number"] { - width: 50px; + width: 40px; } select { - width: 110px; + width: 100px; } - .range-separator { - color: #6c757d; - font-weight: bold; - margin: 0 4px; + .filter-section { + display: flex; + align-items: flex-start; + gap: 5px; + margin-bottom: 3px; } - .section-label { font-weight: bold; font-size: 10px; @@ -181,18 +145,11 @@ color: #000; } - .checkbox-sections-container { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - } - .checkbox-container { display: flex; flex-wrap: wrap; - gap: 4px; - max-height: 150px; - overflow-y: auto; + gap: 10px; + flex: 1; } .checkbox-item { @@ -200,8 +157,6 @@ align-items: center; font-size: 9px; white-space: nowrap; - width: calc(50% - 2px); - min-width: 80px; } .checkbox-item input[type="checkbox"] { @@ -225,9 +180,8 @@ .search-actions { display: flex; - gap: 10px; - margin-top: 15px; - justify-content: flex-start; + gap: 5px; + margin-top: 3px; } .btn { @@ -475,52 +429,32 @@