Compare commits

...

2 commits

Author SHA1 Message Date
erik
6c646719dd reduced duplicate insert errors of portals, still present because of two players disovering the same portal at the same time, other changes to inventory 2025-09-22 18:21:04 +00:00
erik
e7ca39318f Fix score-based ordering in suitbuilder frontend
Updated JavaScript to maintain score ordering during streaming search:
- Replace addSuitToResults() with insertSuitInScoreOrder()
- Add regenerateResultsDisplay() to maintain proper DOM ordering
- Medal assignment (🥇🥈🥉) now based on score ranking, not arrival order
- Suits with highest scores now always appear at top during live search
- Updated displaySuitResults() to sort by score before displaying

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-03 20:02:01 +00:00
6 changed files with 1360 additions and 402 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
@app.get("/sets/list")
return HealthResponse(
status="healthy" if db_connected else "degraded",
timestamp=datetime.now(),
database_connected=db_connected,
version="1.0.0"
)
@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 {
@ -3631,6 +3970,15 @@ async def stream_optimize_suits(
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 {
"event": "complete",
@ -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

38
main.py
View file

@ -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,7 +2004,9 @@ async def ws_receive_snapshots(
)
if not existing_portal:
# Store new portal in database
# 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,
@ -2016,14 +2018,34 @@ async def ws_receive_snapshots(
)
)
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
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,

View file

@ -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,11 +475,27 @@
<div class="main-content">
<form class="search-form" id="inventorySearchForm">
<!-- Row 0: Equipment Type Selection -->
<!-- Basic Filters -->
<div class="filter-card">
<div class="filter-card-header">Basic Search</div>
<div class="filter-row">
<div class="filter-group">
<label>Name:</label>
<input type="text" id="searchText" placeholder="Item name">
</div>
<div class="filter-group">
<label>Status:</label>
<select id="searchEquipStatus">
<option value="all">All</option>
<option value="equipped">Equipped</option>
<option value="unequipped">Inventory</option>
</select>
</div>
<div class="filter-group">
<label>Type:</label>
<div style="display: flex; gap: 10px;">
<div style="display: flex; gap: 8px;">
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="armorOnly" value="armor" checked style="margin-right: 3px;">
Armor Only
@ -442,16 +504,20 @@
<input type="radio" name="equipmentType" id="jewelryOnly" value="jewelry" style="margin-right: 3px;">
Jewelry Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="shirtOnly" value="shirt" style="margin-right: 3px;">
Shirts Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="pantsOnly" value="pants" style="margin-right: 3px;">
Pants Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="allItems" value="all" style="margin-right: 3px;">
All Items
</label>
</div>
</div>
</div>
<!-- Row 0.5: Slot Selection -->
<div class="filter-row">
<div class="filter-group">
<label>Slot:</label>
<select id="slotFilter">
@ -480,51 +546,39 @@
</div>
</div>
<!-- Row 1: Basic filters -->
<div class="filter-row">
<div class="filter-group">
<label>Name:</label>
<input type="text" id="searchText" placeholder="Item name">
</div>
<div class="filter-group">
<label>Status:</label>
<select id="searchEquipStatus">
<option value="all">All</option>
<option value="equipped">Equipped</option>
<option value="unequipped">Inventory</option>
</select>
</div>
</div>
<!-- Row 2: Stats -->
<!-- Stats Filters -->
<div class="filter-card">
<div class="filter-card-header">Item Stats</div>
<div class="filter-row">
<div class="filter-group">
<label>Armor:</label>
<input type="number" id="searchMinArmor" placeholder="Min">
<span>-</span>
<span class="range-separator">-</span>
<input type="number" id="searchMaxArmor" placeholder="Max">
</div>
<div class="filter-group">
<label>Crit:</label>
<label>Crit Dmg:</label>
<input type="number" id="searchMinCritDamage" placeholder="Min">
<span>-</span>
<span class="range-separator">-</span>
<input type="number" id="searchMaxCritDamage" placeholder="Max">
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label>Dmg:</label>
<label>Dmg Rating:</label>
<input type="number" id="searchMinDamageRating" placeholder="Min">
<span>-</span>
<span class="range-separator">-</span>
<input type="number" id="searchMaxDamageRating" placeholder="Max">
</div>
<div class="filter-group">
<label>Heal:</label>
<label>Heal Boost:</label>
<input type="number" id="searchMinHealBoost" placeholder="Min">
<span>-</span>
<span class="range-separator">-</span>
<input type="number" id="searchMaxHealBoost" placeholder="Max">
</div>
</div>
<!-- New Rating Filters -->
<div class="filter-row">
<div class="filter-group">
<label>Vitality:</label>
@ -534,11 +588,18 @@
<label>Dmg Resist:</label>
<input type="number" id="searchMinDamageResistRating" placeholder="Min">
</div>
<div class="filter-group">
<label>Crit Dmg Resist:</label>
<input type="number" id="searchMinCritDamageResistRating" placeholder="Min">
</div>
</div>
</div>
<!-- Checkbox Sections in Grid Layout -->
<div class="checkbox-sections-container">
<!-- Equipment Sets -->
<div class="filter-section">
<label class="section-label">Set:</label>
<div class="filter-card">
<div class="filter-card-header">Equipment Sets</div>
<div class="checkbox-container" id="equipmentSets">
<div class="checkbox-item">
<input type="checkbox" id="set_14" value="14">
@ -608,8 +669,8 @@
</div>
<!-- Legendary Cantrips -->
<div class="filter-section">
<label class="section-label">Cantrips:</label>
<div class="filter-card">
<div class="filter-card-header">Legendary Cantrips</div>
<div class="checkbox-container" id="cantrips">
<!-- Legendary Attributes -->
<div class="checkbox-item">
@ -785,8 +846,8 @@
</div>
<!-- Legendary Wards -->
<div class="filter-section">
<label class="section-label">Wards:</label>
<div class="filter-card">
<div class="filter-card-header">Legendary Wards</div>
<div class="checkbox-container" id="protections">
<div class="checkbox-item">
<input type="checkbox" id="protection_flame" value="Legendary Flame Ward">
@ -824,12 +885,9 @@
</div>
<!-- Equipment Slots -->
<div class="filter-section">
<label class="section-label">Equipment Slots:</label>
<!-- Armor Slots -->
<div class="checkbox-container" id="armor-slots">
<label class="subsection-label">Armor:</label>
<div class="filter-card">
<div class="filter-card-header">Equipment Slots</div>
<div class="checkbox-container" id="all-slots">
<div class="checkbox-item">
<input type="checkbox" id="slot_head" value="Head">
<label for="slot_head">Head</label>
@ -870,11 +928,6 @@
<input type="checkbox" id="slot_shield" value="Shield">
<label for="slot_shield">Shield</label>
</div>
</div>
<!-- Jewelry Slots -->
<div class="checkbox-container" id="jewelry-slots">
<label class="subsection-label">Jewelry:</label>
<div class="checkbox-item">
<input type="checkbox" id="slot_neck" value="Neck">
<label for="slot_neck">Neck</label>
@ -893,6 +946,7 @@
</div>
</div>
</div>
</div>
<div class="search-actions">
<button type="button" class="btn btn-secondary" id="clearBtn">Clear All</button>

View file

@ -255,6 +255,10 @@ function buildSearchParameters() {
params.append('armor_only', 'true');
} else if (equipmentType === 'jewelry') {
params.append('jewelry_only', 'true');
} else if (equipmentType === 'shirt') {
params.append('shirt_only', 'true');
} else if (equipmentType === 'pants') {
params.append('pants_only', 'true');
}
// If 'all' is selected, don't add any type filter
@ -298,6 +302,7 @@ function buildSearchParameters() {
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
addParam(params, 'min_vitality_rating', 'searchMinVitalityRating');
addParam(params, 'min_damage_resist_rating', 'searchMinDamageResistRating');
addParam(params, 'min_crit_damage_resist_rating', 'searchMinCritDamageResistRating');
// Requirements parameters
addParam(params, 'min_level', 'searchMinLevel');
@ -438,6 +443,7 @@ function displayResults(data) {
<th class="text-right sortable" data-sort="heal_boost_rating">Heal Boost${getSortIcon('heal_boost_rating')}</th>
<th class="text-right sortable" data-sort="vitality_rating">Vitality${getSortIcon('vitality_rating')}</th>
<th class="text-right sortable" data-sort="damage_resist_rating">Dmg Resist${getSortIcon('damage_resist_rating')}</th>
<th class="text-right sortable" data-sort="crit_damage_resist_rating">Crit Dmg Resist${getSortIcon('crit_damage_resist_rating')}</th>
<th class="text-right sortable" data-sort="last_updated">Last Updated${getSortIcon('last_updated')}</th>
</tr>
</thead>
@ -451,6 +457,7 @@ function displayResults(data) {
const healBoostRating = item.heal_boost_rating > 0 ? item.heal_boost_rating : '-';
const vitalityRating = item.vitality_rating > 0 ? item.vitality_rating : '-';
const damageResistRating = item.damage_resist_rating > 0 ? item.damage_resist_rating : '-';
const critDamageResistRating = item.crit_damage_resist_rating > 0 ? item.crit_damage_resist_rating : '-';
const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory';
const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory';
@ -522,6 +529,7 @@ function displayResults(data) {
<td class="text-right">${healBoostRating}</td>
<td class="text-right">${vitalityRating}</td>
<td class="text-right">${damageResistRating}</td>
<td class="text-right">${critDamageResistRating}</td>
<td class="text-right">${lastUpdated}</td>
</tr>
`;

View file

@ -657,6 +657,176 @@ body {
margin-left: 4px;
}
/* Ratings display */
.item-ratings {
color: #0066cc;
font-size: 11px;
font-weight: 600;
background: #e6f3ff;
padding: 1px 4px;
border-radius: 2px;
margin-left: 4px;
}
/* Empty slot styling */
.suit-item-entry.empty-slot {
opacity: 0.6;
background: #f8f9fa;
border-left: 3px solid #dee2e6;
padding-left: 8px;
}
.empty-slot-text {
color: #6c757d;
font-style: italic;
font-size: 11px;
}
/* New Column-Based Table Layout */
.suit-items-table {
width: 100%;
font-size: 12px;
margin-top: 8px;
}
.suit-items-header {
display: grid;
grid-template-columns: 80px 120px 250px 140px 250px 60px 120px;
gap: 8px;
background: #2c3e50;
color: white;
padding: 8px 4px;
font-weight: 600;
font-size: 11px;
border-radius: 4px 4px 0 0;
}
.suit-items-header > div {
color: white !important;
opacity: 1 !important;
}
.suit-items-body {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 4px 4px;
}
.suit-item-row {
display: grid;
grid-template-columns: 80px 120px 250px 140px 250px 60px 120px;
gap: 8px;
padding: 6px 4px;
border-bottom: 1px solid #e9ecef;
align-items: center;
min-height: 24px;
}
.suit-item-row:last-child {
border-bottom: none;
}
.suit-item-row:nth-child(even) {
background: #ffffff;
}
.suit-item-row.empty-slot {
opacity: 0.5;
color: #6c757d;
font-style: italic;
}
/* Column styling */
.col-slot {
font-weight: 600;
color: #495057;
}
.col-character {
color: #666;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-item {
color: #333;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-set {
font-weight: 600;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suit-items-body .col-set {
color: #1f2937;
background: #f3f4f6;
padding: 2px 4px;
border-radius: 3px;
}
.col-spells {
color: #7c3aed;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-armor {
color: #059669;
font-weight: 600;
text-align: center;
}
.col-ratings {
color: #0066cc;
font-size: 11px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.need-reducing {
color: #dc2626;
font-weight: bold;
margin-left: 2px;
}
/* Responsive adjustments for table */
@media (max-width: 1200px) {
.suit-items-header,
.suit-item-row {
grid-template-columns: 70px 100px 200px 120px 200px 50px 100px;
gap: 6px;
font-size: 11px;
}
}
@media (max-width: 900px) {
.suit-items-header,
.suit-item-row {
grid-template-columns: 60px 80px 150px 100px 150px 40px 80px;
gap: 4px;
font-size: 10px;
}
.col-spells,
.col-ratings {
font-size: 10px;
}
}
/* Progressive Search Styles */
.search-progress {
margin-top: 15px;

View file

@ -1,10 +1,12 @@
// Suitbuilder JavaScript - Constraint Solver Frontend Logic
console.log('Suitbuilder.js loaded - VERSION: SCORE_ORDERING_AND_CANCELLATION_FIX_v3');
// Configuration
const API_BASE = '/inv';
const API_BASE = '/inv/suitbuilder';
let currentSuits = [];
let lockedSlots = new Set();
let selectedSuit = null;
let currentSearchController = null; // AbortController for current search
// Initialize when page loads
document.addEventListener('DOMContentLoaded', function() {
@ -25,7 +27,7 @@ function initializeSuitbuilder() {
*/
async function loadCharacters() {
try {
const response = await fetch(`${API_BASE}/characters/list`);
const response = await fetch(`${API_BASE}/characters`);
if (!response.ok) {
throw new Error('Failed to load characters');
}
@ -52,11 +54,13 @@ function displayCharacters(characters) {
let html = '';
characters.forEach(character => {
// Sanitize character name for HTML ID (replace special chars with underscores)
const safeId = character.replace(/[^a-zA-Z0-9]/g, '_');
html += `
<div class="checkbox-item">
<input type="checkbox" id="char_${character.character_name}"
class="character-checkbox" value="${character.character_name}" checked>
<label for="char_${character.character_name}">${character.character_name}</label>
<input type="checkbox" id="char_${safeId}"
class="character-checkbox" value="${character}" checked>
<label for="char_${safeId}">${character}</label>
</div>
`;
});
@ -161,11 +165,16 @@ async function performSuitSearch() {
try {
await streamOptimalSuits(constraints);
} catch (error) {
// Don't show error for user-cancelled searches
if (error.name === 'AbortError') {
console.log('Search cancelled by user');
} else {
console.error('Suit search error:', error);
resultsDiv.innerHTML = `<div class="error">❌ Suit search failed: ${error.message}</div>`;
countSpan.textContent = '';
}
}
}
/**
* Gather all current constraints from the form
@ -234,66 +243,208 @@ function validateConstraints(constraints) {
* Stream optimal suits using Server-Sent Events with progressive results
*/
async function streamOptimalSuits(constraints) {
// Build request parameters for the streaming constraint solver
const params = new URLSearchParams();
// Prepare constraint data for POST request
const requestBody = {
characters: constraints.characters.length > 0 ? constraints.characters : [],
primary_set: constraints.primary_set ? parseInt(constraints.primary_set) : null,
secondary_set: constraints.secondary_set ? parseInt(constraints.secondary_set) : null,
required_spells: [
...constraints.legendary_cantrips,
...constraints.protection_spells
],
locked_items: {}, // TODO: implement locked items
include_equipped: constraints.include_equipped,
include_inventory: constraints.include_inventory,
min_armor: constraints.min_armor ? parseInt(constraints.min_armor) : null,
max_armor: constraints.max_armor ? parseInt(constraints.max_armor) : null,
min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null,
max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null,
min_damage_rating: constraints.min_damage_rating ? parseInt(constraints.min_damage_rating) : null,
max_damage_rating: constraints.max_damage_rating ? parseInt(constraints.max_damage_rating) : null,
max_results: 10,
search_timeout: 300
};
// Character selection
if (constraints.characters.length > 0) {
params.append('characters', constraints.characters.join(','));
} else {
params.append('include_all_characters', 'true');
console.log('Starting suit search with constraints:', requestBody);
// Cancel any existing search
if (currentSearchController) {
currentSearchController.abort();
}
// Equipment sets
if (constraints.primary_set) {
params.append('primary_set', constraints.primary_set);
}
if (constraints.secondary_set) {
params.append('secondary_set', constraints.secondary_set);
// Create new AbortController for this search
currentSearchController = new AbortController();
// Use fetch with streaming response instead of EventSource for POST support
const response = await fetch(`${API_BASE}/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: currentSearchController.signal // Add abort signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
// Legendary cantrips
if (constraints.legendary_cantrips.length > 0) {
params.append('legendary_cantrips', constraints.legendary_cantrips.join(','));
}
// Legendary wards
if (constraints.protection_spells.length > 0) {
params.append('legendary_wards', constraints.protection_spells.join(','));
}
// Rating constraints
if (constraints.min_armor) params.append('min_armor', constraints.min_armor);
if (constraints.max_armor) params.append('max_armor', constraints.max_armor);
if (constraints.min_crit_damage) params.append('min_crit_damage', constraints.min_crit_damage);
if (constraints.max_crit_damage) params.append('max_crit_damage', constraints.max_crit_damage);
if (constraints.min_damage_rating) params.append('min_damage_rating', constraints.min_damage_rating);
if (constraints.max_damage_rating) params.append('max_damage_rating', constraints.max_damage_rating);
// Equipment status
params.append('include_equipped', constraints.include_equipped.toString());
params.append('include_inventory', constraints.include_inventory.toString());
// Locked slots
if (lockedSlots.size > 0) {
params.append('locked_slots', Array.from(lockedSlots).join(','));
}
// Search depth (default to balanced)
params.append('search_depth', 'balanced');
const streamUrl = `${API_BASE}/optimize/suits/stream?${params.toString()}`;
console.log('Streaming suits with URL:', streamUrl);
const reader = response.body.getReader();
const decoder = new TextDecoder();
return new Promise((resolve, reject) => {
const eventSource = new EventSource(streamUrl);
let searchStopped = false;
let buffer = '';
async function readStream() {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
resolve();
break;
}
if (searchStopped) {
await reader.cancel();
resolve();
break;
}
// Process SSE data
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
let currentEventType = null;
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEventType = line.substring(7).trim();
continue;
}
if (line.startsWith('data: ')) {
const data = line.substring(6);
try {
const eventData = JSON.parse(data);
// Handle different event types based on the current event type
if (currentEventType === 'suit') {
handleSuitEvent(eventData);
} else if (currentEventType === 'progress') {
handleProgressEvent(eventData);
} else if (currentEventType === 'complete') {
handleCompleteEvent(eventData);
resolve();
return;
} else if (currentEventType === 'error') {
handleErrorEvent(eventData);
reject(new Error(eventData.message || 'Search error'));
return;
}
// Reset event type after processing
currentEventType = null;
} catch (parseError) {
console.warn('Failed to parse SSE data:', data, 'Event type:', currentEventType);
}
}
}
}
} catch (error) {
// Don't treat abort as an error
if (error.name === 'AbortError') {
console.log('Search was aborted by user');
resolve();
} else {
reject(error);
}
}
}
readStream();
// Event handlers
function handleSuitEvent(data) {
try {
console.log('NEW handleSuitEvent called with data:', data);
// Transform backend suit format to frontend format
const transformedSuit = transformSuitData(data);
console.log('Transformed suit:', transformedSuit);
// Insert suit in score-ordered position (highest score first)
const insertIndex = insertSuitInScoreOrder(transformedSuit);
console.log('Insert index returned:', insertIndex);
// Insert DOM element at the correct position instead of regenerating everything
insertSuitDOMAtPosition(transformedSuit, insertIndex);
console.log('DOM insertion complete');
// Update count
document.getElementById('foundCount').textContent = currentSuits.length;
document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
} catch (error) {
console.error('Error processing suit data:', error);
console.error('Stack trace:', error.stack);
}
}
function handleProgressEvent(data) {
try {
document.getElementById('foundCount').textContent = data.found || currentSuits.length;
document.getElementById('checkedCount').textContent = data.evaluated || 0;
document.getElementById('elapsedTime').textContent = data.elapsed || '0.0';
} catch (error) {
console.error('Error processing progress data:', error);
}
}
function handleCompleteEvent(data) {
try {
// Hide loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) {
loadingDiv.innerHTML = `✅ Search complete! Found ${data.suits_found} suits in ${data.duration}s.`;
}
// Update final results count
const countSpan = document.getElementById('resultsCount');
if (countSpan) {
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
}
} catch (error) {
console.error('Error processing completion data:', error);
}
}
function handleErrorEvent(data) {
try {
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) {
loadingDiv.innerHTML = `❌ Search error: ${data.message}`;
}
} catch (error) {
console.error('Error processing error data:', error);
}
}
// Add stop search functionality
const stopButton = document.getElementById('stopSearch');
stopButton.addEventListener('click', () => {
searchStopped = true;
eventSource.close();
// Actually abort the HTTP request
if (currentSearchController) {
currentSearchController.abort();
currentSearchController = null;
}
// Update UI to show search was stopped
const loadingDiv = document.querySelector('.loading');
@ -306,118 +457,7 @@ async function streamOptimalSuits(constraints) {
if (countSpan) {
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''} (search stopped)`;
}
resolve();
});
// Handle individual suit results
eventSource.addEventListener('suit', (event) => {
try {
const suit = JSON.parse(event.data);
// Transform backend suit format to frontend format
const transformedSuit = transformSuitData(suit);
currentSuits.push(transformedSuit);
// Add suit to streaming results
addSuitToResults(transformedSuit, currentSuits.length - 1);
// Update count
document.getElementById('foundCount').textContent = currentSuits.length;
document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
} catch (error) {
console.error('Error processing suit data:', error);
}
});
// Handle progress updates
eventSource.addEventListener('progress', (event) => {
try {
const progress = JSON.parse(event.data);
document.getElementById('foundCount').textContent = progress.found || currentSuits.length;
document.getElementById('checkedCount').textContent = progress.checked || 0;
document.getElementById('elapsedTime').textContent = progress.elapsed || '0.0';
} catch (error) {
console.error('Error processing progress data:', error);
}
});
// Handle search completion
eventSource.addEventListener('complete', (event) => {
try {
const completion = JSON.parse(event.data);
// Hide loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) {
loadingDiv.innerHTML = `✅ Search complete! Found ${completion.total_found} suits in ${completion.total_time}s.`;
}
// Update final results count
const countSpan = document.getElementById('resultsCount');
if (countSpan) {
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
}
eventSource.close();
resolve();
} catch (error) {
console.error('Error processing completion data:', error);
eventSource.close();
resolve();
}
});
// Handle timeout
eventSource.addEventListener('timeout', (event) => {
try {
const timeout = JSON.parse(event.data);
// Update UI to show timeout
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) {
loadingDiv.innerHTML = `${timeout.message}`;
}
eventSource.close();
resolve();
} catch (error) {
console.error('Error processing timeout data:', error);
eventSource.close();
resolve();
}
});
// Handle errors
eventSource.addEventListener('error', (event) => {
try {
const errorData = JSON.parse(event.data);
console.error('Stream error:', errorData.message);
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) {
loadingDiv.innerHTML = `❌ Search error: ${errorData.message}`;
}
} catch (error) {
console.error('Error parsing error data:', error);
}
eventSource.close();
reject(new Error('Stream error occurred'));
});
// Handle connection errors
eventSource.onerror = (event) => {
if (!searchStopped) {
console.error('EventSource error:', event);
eventSource.close();
reject(new Error('Connection error during streaming'));
}
};
});
}
@ -458,12 +498,42 @@ function transformSuitData(suit) {
}
/**
* Add a single suit to the streaming results display
* Insert a suit into the currentSuits array in score-ordered position (highest first)
*/
function addSuitToResults(suit, index) {
function insertSuitInScoreOrder(suit) {
console.log(`Inserting suit with score ${suit.score}. Current suits:`, currentSuits.map(s => s.score));
// Find the correct position to insert the suit (highest score first)
let insertIndex = 0;
for (let i = 0; i < currentSuits.length; i++) {
if (suit.score > currentSuits[i].score) {
insertIndex = i;
break;
}
insertIndex = i + 1;
}
// Insert the suit at the correct position
currentSuits.splice(insertIndex, 0, suit);
console.log(`Inserted at index ${insertIndex}. New order:`, currentSuits.map(s => s.score));
return insertIndex;
}
/**
* Regenerate the entire results display to maintain proper score ordering
*/
function regenerateResultsDisplay() {
console.log('Regenerating display with suits:', currentSuits.map(s => `Score: ${s.score}, ID: ${s.id}`));
const streamingResults = document.getElementById('streamingResults');
if (!streamingResults) return;
// Clear existing results
streamingResults.innerHTML = '';
// Re-add all suits in their current (score-ordered) positions
currentSuits.forEach((suit, index) => {
const scoreClass = getScoreClass(suit.score);
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸';
@ -486,15 +556,90 @@ function addSuitToResults(suit, index) {
`;
streamingResults.insertAdjacentHTML('beforeend', suitHtml);
});
// Re-add click handlers for all suits
document.querySelectorAll('.suit-item').forEach(item => {
item.addEventListener('click', function() {
const suitId = parseInt(this.dataset.suitId);
selectSuit(suitId);
});
});
}
/**
* Insert a suit DOM element at the correct position and update all medal rankings
*/
function insertSuitDOMAtPosition(suit, insertIndex) {
console.log('insertSuitDOMAtPosition called with suit score:', suit.score, 'at index:', insertIndex);
const streamingResults = document.getElementById('streamingResults');
if (!streamingResults) {
console.error('streamingResults element not found!');
return;
}
console.log('Current DOM children count:', streamingResults.children.length);
// Create the new suit HTML
const scoreClass = getScoreClass(suit.score);
const suitHtml = `
<div class="suit-item" data-suit-id="${suit.id}">
<div class="suit-header">
<div class="suit-score ${scoreClass}">
🔸 Suit #${suit.id} (Score: ${suit.score})
</div>
</div>
<div class="suit-stats">
${formatSuitStats(suit)}
</div>
<div class="suit-items">
${formatSuitItems(suit.items)}
${suit.missing && suit.missing.length > 0 ? `<div class="missing-items">Missing: ${suit.missing.join(', ')}</div>` : ''}
${suit.notes && suit.notes.length > 0 ? `<div class="suit-notes">${suit.notes.join(' • ')}</div>` : ''}
</div>
</div>
`;
// Insert at the correct position
const existingSuits = streamingResults.children;
if (insertIndex >= existingSuits.length) {
// Insert at the end
streamingResults.insertAdjacentHTML('beforeend', suitHtml);
} else {
// Insert before the suit at insertIndex
existingSuits[insertIndex].insertAdjacentHTML('beforebegin', suitHtml);
}
// Update all medal rankings after insertion
updateAllMedals();
// Add click handler for the new suit
const newSuitElement = streamingResults.lastElementChild;
const newSuitElement = streamingResults.children[insertIndex];
newSuitElement.addEventListener('click', function() {
const suitId = parseInt(this.dataset.suitId);
selectSuit(suitId);
});
}
/**
* Update medal rankings for all displayed suits
*/
function updateAllMedals() {
const streamingResults = document.getElementById('streamingResults');
if (!streamingResults) return;
Array.from(streamingResults.children).forEach((suitElement, index) => {
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸';
const scoreElement = suitElement.querySelector('.suit-score');
if (scoreElement) {
const scoreText = scoreElement.textContent;
// Replace the existing medal with the new one
scoreElement.textContent = scoreText.replace(/^[🥇🥈🥉🔸]\s*/, medal + ' ');
}
});
}
/**
* Generate suit combinations from available items
* This is a simplified algorithm - the full constraint solver will be more sophisticated
@ -612,10 +757,13 @@ function displaySuitResults(suits) {
return;
}
countSpan.textContent = `Found ${suits.length} suit${suits.length !== 1 ? 's' : ''}`;
// Sort suits by score (highest first) before displaying
const sortedSuits = [...suits].sort((a, b) => b.score - a.score);
countSpan.textContent = `Found ${sortedSuits.length} suit${sortedSuits.length !== 1 ? 's' : ''}`;
let html = '';
suits.forEach((suit, index) => {
sortedSuits.forEach((suit, index) => {
const scoreClass = getScoreClass(suit.score);
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🔸';
@ -660,33 +808,177 @@ function getScoreClass(score) {
}
/**
* Format suit items for display
* Format suit items for display - shows ALL armor slots even if empty
*/
function formatSuitItems(items) {
let html = '';
console.log(`[DEBUG] formatSuitItems called with items:`, items);
if (!items || Object.keys(items).length === 0) {
return '<div class="no-items">No items in this suit</div>';
}
// Define all expected armor/equipment slots in logical order
const allSlots = [
// Armor slots
'Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands',
'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet',
// Jewelry slots
'Neck', 'Left Ring', 'Right Ring', 'Left Wrist', 'Right Wrist', 'Trinket',
// Clothing slots
'Shirt', 'Pants'
];
Object.entries(items).forEach(([slot, item]) => {
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
const properties = formatItemProperties(item);
console.log(`[DEBUG] allSlots:`, allSlots);
// Create table structure with header
let html = `
<div class="suit-items-table">
<div class="suit-items-header">
<div class="col-slot">Slot</div>
<div class="col-character">Character</div>
<div class="col-item">Item</div>
<div class="col-set">Set</div>
<div class="col-spells">Spells</div>
<div class="col-armor">Armor</div>
<div class="col-ratings">Ratings</div>
</div>
<div class="suit-items-body">
`;
allSlots.forEach(slot => {
const item = items ? items[slot] : null;
// DEBUG: Log all slots and items
console.log(`[DEBUG] Processing slot '${slot}', item:`, item);
if (item) {
// Item exists in this slot
const character = item.source_character || item.character_name || 'Unknown';
const itemName = item.name || 'Unknown Item';
// Only show set names for armor items (not jewelry or clothing)
const armorSlots = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands',
'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet'];
const setName = (armorSlots.includes(slot) && item.set_name) ? item.set_name : '-';
const spells = formatItemSpells(item);
const armor = formatItemArmor(item);
const ratings = formatItemRatingsColumns(item);
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">*</span>' : '';
html += `
<div class="suit-item-entry">
<strong>${slot}:</strong>
<span class="item-character">${item.character_name}</span> -
<span class="item-name">${item.name}</span>
${properties ? `<span class="item-properties">(${properties})</span>` : ''}
${needsReducing}
<div class="suit-item-row">
<div class="col-slot">${slot}</div>
<div class="col-character">${character}</div>
<div class="col-item">${itemName}${needsReducing}</div>
<div class="col-set">${setName}</div>
<div class="col-spells">${spells}</div>
<div class="col-armor">${armor}</div>
<div class="col-ratings">${ratings}</div>
</div>
`;
} else {
// Empty slot
html += `
<div class="suit-item-row empty-slot">
<div class="col-slot">${slot}</div>
<div class="col-character">-</div>
<div class="col-item">-</div>
<div class="col-set">-</div>
<div class="col-spells">-</div>
<div class="col-armor">-</div>
<div class="col-ratings">-</div>
</div>
`;
}
});
html += `
</div>
</div>
`;
return html;
}
/**
* Format item spells for column display (focus on Legendary spells)
*/
function formatItemSpells(item) {
const spellArray = item.spells || item.spell_names || [];
if (!Array.isArray(spellArray) || spellArray.length === 0) {
return '-';
}
// Filter for important spells (Legendary, Epic)
const importantSpells = spellArray.filter(spell =>
spell.includes('Legendary') || spell.includes('Epic')
);
if (importantSpells.length === 0) {
return `${spellArray.length} spells`;
}
// Show up to 2 important spells, abbreviate the rest
const displaySpells = importantSpells.slice(0, 2);
let result = displaySpells.join(', ');
if (importantSpells.length > 2) {
result += ` +${importantSpells.length - 2} more`;
}
return result;
}
/**
* Format item armor for column display
*/
function formatItemArmor(item) {
if (item.armor_level && item.armor_level > 0) {
return item.armor_level.toString();
}
return '-';
}
/**
* Format item ratings for column display
*/
function formatItemRatingsColumns(item) {
const ratings = [];
// Access ratings from the ratings object if available, fallback to direct properties
const itemRatings = item.ratings || item;
// Helper function to get rating value, treating null/undefined/negative as 0
function getRatingValue(value) {
if (value === null || value === undefined || value < 0) return 0;
return Math.round(value); // Round to nearest integer
}
// Determine if this is clothing (shirts/pants) or armor
// Check item name patterns since ObjectClass 3 items (clothing) may appear in various slots
const itemName = item.name || '';
const isClothing = itemName.toLowerCase().includes('shirt') ||
itemName.toLowerCase().includes('pants') ||
itemName.toLowerCase().includes('breeches') ||
itemName.toLowerCase().includes('baggy') ||
(item.slot === 'Shirt' || item.slot === 'Pants');
if (isClothing) {
// Clothing: Show DR and DRR
const damageRating = getRatingValue(itemRatings.damage_rating);
const damageResist = getRatingValue(itemRatings.damage_resist_rating);
ratings.push(`DR${damageRating}`);
ratings.push(`DRR${damageResist}`);
} else {
// Armor: Show CD and CDR
const critDamage = getRatingValue(itemRatings.crit_damage_rating);
const critDamageResist = getRatingValue(itemRatings.crit_damage_resist_rating);
ratings.push(`CD${critDamage}`);
ratings.push(`CDR${critDamageResist}`);
}
return ratings.join(' ');
}
/**
* Check if item is multi-slot and needs reducing
* Only armor items need reduction - jewelry can naturally go in multiple slots
@ -781,6 +1073,47 @@ function formatItemProperties(item) {
return properties.join(', ');
}
/**
* Format item ratings for display (separate from properties)
*/
function formatItemRatings(item) {
const ratings = [];
// Armor level
if (item.armor_level && item.armor_level > 0) {
ratings.push(`AL ${item.armor_level}`);
}
// Damage ratings
if (item.crit_damage_rating && item.crit_damage_rating > 0) {
ratings.push(`CD +${item.crit_damage_rating}`);
}
if (item.damage_rating && item.damage_rating > 0) {
ratings.push(`DR +${item.damage_rating}`);
}
// Resist ratings
if (item.crit_damage_resist_rating && item.crit_damage_resist_rating > 0) {
ratings.push(`CDR +${item.crit_damage_resist_rating}`);
}
if (item.damage_resist_rating && item.damage_resist_rating > 0) {
ratings.push(`DRR +${item.damage_resist_rating}`);
}
// Other ratings
if (item.heal_boost_rating && item.heal_boost_rating > 0) {
ratings.push(`HB +${item.heal_boost_rating}`);
}
if (item.vitality_rating && item.vitality_rating > 0) {
ratings.push(`VIT +${item.vitality_rating}`);
}
return ratings.join(', ');
}
/**
* Select a suit and populate the visual slots
*/
@ -821,7 +1154,7 @@ function populateVisualSlots(items) {
slotElement.innerHTML = `
<div class="slot-item-name">${item.name}</div>
<div class="slot-item-character">${item.character_name}</div>
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
<div class="slot-item-properties">${formatItemProperties(item)}</div>
${needsReducing}
`;