reduced duplicate insert errors of portals, still present because of two players disovering the same portal at the same time, other changes to inventory

This commit is contained in:
erik 2025-09-22 18:21:04 +00:00
parent e7ca39318f
commit 6c646719dd
6 changed files with 1093 additions and 232 deletions

View file

@ -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