added inventory service for armor and jewelry

This commit is contained in:
erik 2025-06-12 23:05:33 +00:00
parent 09a6cd4946
commit 57a2384511
13 changed files with 2630 additions and 25 deletions

318
main.py
View file

@ -13,8 +13,8 @@ import sys
from typing import Dict, List, Any
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import JSONResponse, Response
from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder
@ -386,6 +386,64 @@ async def get_total_rares():
raise HTTPException(status_code=500, detail="Internal server error")
# --- GET Spawn Heat Map Endpoint ---------------------------------
@app.get("/spawns/heatmap")
async def get_spawn_heatmap_data(
hours: int = Query(24, ge=1, le=168, description="Lookback window in hours (1-168)"),
limit: int = Query(10000, ge=100, le=50000, description="Maximum number of spawn points to return")
):
"""
Aggregate spawn locations for heat-map visualization.
Returns spawn event coordinates grouped by location with intensity counts
for the specified time window.
Response format:
{
"spawn_points": [{"ew": float, "ns": float, "intensity": int}, ...],
"total_points": int,
"timestamp": "UTC-ISO"
}
"""
try:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
# Aggregate spawn events by coordinates within time window
query = """
SELECT ew, ns, COUNT(*) AS spawn_count
FROM spawn_events
WHERE timestamp >= :cutoff
GROUP BY ew, ns
ORDER BY spawn_count DESC
LIMIT :limit
"""
rows = await database.fetch_all(query, {"cutoff": cutoff, "limit": limit})
spawn_points = [
{
"ew": float(row["ew"]),
"ns": float(row["ns"]),
"intensity": int(row["spawn_count"])
}
for row in rows
]
result = {
"spawn_points": spawn_points,
"total_points": len(spawn_points),
"timestamp": datetime.now(timezone.utc).isoformat(),
"hours_window": hours
}
logger.debug(f"Heat map data: {len(spawn_points)} unique spawn locations from last {hours} hours")
return JSONResponse(content=jsonable_encoder(result))
except Exception as e:
logger.error(f"Heat map query failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Spawn heat map query failed")
# --- GET Inventory Endpoints ---------------------------------
@app.get("/inventory/{character_name}")
async def get_character_inventory(character_name: str):
@ -514,6 +572,230 @@ async def list_characters_with_inventories():
raise HTTPException(status_code=500, detail="Internal server error")
# --- Inventory Service Character List Proxy ---------------------
@app.get("/inventory-characters")
async def get_inventory_characters():
"""Get character list from inventory service - proxy to avoid routing conflicts."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{INVENTORY_SERVICE_URL}/characters/list")
if response.status_code == 200:
return JSONResponse(content=response.json())
else:
logger.error(f"Inventory service returned {response.status_code}: {response.text}")
raise HTTPException(status_code=response.status_code, detail="Failed to get characters from inventory service")
except Exception as e:
logger.error(f"Failed to proxy inventory characters request: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to get inventory characters")
# --- Inventory Search Service Proxy Endpoints -------------------
@app.get("/search/items")
async def search_items_proxy(
text: str = Query(None, description="Search item names, descriptions, or properties"),
character: str = Query(None, description="Limit search to specific character"),
include_all_characters: bool = Query(False, description="Search across all characters"),
equipment_status: str = Query(None, description="equipped, unequipped, or all"),
equipment_slot: int = Query(None, description="Equipment slot mask"),
# 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"),
# 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"),
# Combat properties
min_damage: int = Query(None, description="Minimum damage"),
max_damage: int = Query(None, description="Maximum damage"),
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_heal_boost_rating: int = Query(None, description="Minimum heal boost rating"),
max_level: int = Query(None, description="Maximum wield level requirement"),
min_level: int = Query(None, description="Minimum wield level requirement"),
material: str = Query(None, description="Material type (partial match)"),
min_workmanship: float = Query(None, description="Minimum workmanship"),
has_imbue: bool = Query(None, description="Has imbue effects"),
item_set: str = Query(None, description="Item set name (partial match)"),
min_tinks: int = Query(None, description="Minimum tinker count"),
bonded: bool = Query(None, description="Bonded status"),
attuned: bool = Query(None, description="Attuned status"),
unique: bool = Query(None, description="Unique item status"),
is_rare: bool = Query(None, description="Rare item status"),
min_condition: int = Query(None, description="Minimum condition percentage"),
min_value: int = Query(None, description="Minimum item value"),
max_value: int = Query(None, description="Maximum item value"),
max_burden: int = Query(None, description="Maximum burden"),
sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship"),
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=200, description="Items per page")
):
"""Proxy to inventory service comprehensive item search."""
try:
# Build query parameters
params = {}
if text: params["text"] = text
if character: params["character"] = character
if include_all_characters: params["include_all_characters"] = include_all_characters
if equipment_status: params["equipment_status"] = equipment_status
if equipment_slot is not None: params["equipment_slot"] = equipment_slot
# Category filtering
if armor_only: params["armor_only"] = armor_only
if jewelry_only: params["jewelry_only"] = jewelry_only
if weapon_only: params["weapon_only"] = weapon_only
# Spell filtering
if has_spell: params["has_spell"] = has_spell
if spell_contains: params["spell_contains"] = spell_contains
if legendary_cantrips: params["legendary_cantrips"] = legendary_cantrips
# Combat properties
if min_damage is not None: params["min_damage"] = min_damage
if max_damage is not None: params["max_damage"] = max_damage
if min_armor is not None: params["min_armor"] = min_armor
if max_armor is not None: params["max_armor"] = max_armor
if min_attack_bonus is not None: params["min_attack_bonus"] = min_attack_bonus
if min_crit_damage_rating is not None: params["min_crit_damage_rating"] = min_crit_damage_rating
if min_damage_rating is not None: params["min_damage_rating"] = min_damage_rating
if min_heal_boost_rating is not None: params["min_heal_boost_rating"] = min_heal_boost_rating
if max_level is not None: params["max_level"] = max_level
if min_level is not None: params["min_level"] = min_level
if material: params["material"] = material
if min_workmanship is not None: params["min_workmanship"] = min_workmanship
if has_imbue is not None: params["has_imbue"] = has_imbue
if item_set: params["item_set"] = item_set
if min_tinks is not None: params["min_tinks"] = min_tinks
if bonded is not None: params["bonded"] = bonded
if attuned is not None: params["attuned"] = attuned
if unique is not None: params["unique"] = unique
if is_rare is not None: params["is_rare"] = is_rare
if min_condition is not None: params["min_condition"] = min_condition
if min_value is not None: params["min_value"] = min_value
if max_value is not None: params["max_value"] = max_value
if max_burden is not None: params["max_burden"] = max_burden
params["sort_by"] = sort_by
params["sort_dir"] = sort_dir
params["page"] = page
params["limit"] = limit
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{INVENTORY_SERVICE_URL}/search/items",
params=params
)
if response.status_code == 200:
return JSONResponse(content=response.json())
else:
logger.error(f"Inventory search service returned {response.status_code}")
raise HTTPException(status_code=response.status_code, detail="Inventory search service error")
except httpx.RequestError as e:
logger.error(f"Could not reach inventory service: {e}")
raise HTTPException(status_code=503, detail="Inventory service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to search items: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/search/equipped/{character_name}")
async def search_equipped_items_proxy(
character_name: str,
slot: int = Query(None, description="Specific equipment slot mask")
):
"""Proxy to inventory service equipped items search."""
try:
params = {}
if slot is not None:
params["slot"] = slot
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{INVENTORY_SERVICE_URL}/search/equipped/{character_name}",
params=params
)
if response.status_code == 200:
return JSONResponse(content=response.json())
elif response.status_code == 404:
raise HTTPException(status_code=404, detail=f"No equipped items found for character '{character_name}'")
else:
logger.error(f"Inventory service returned {response.status_code} for equipped items search")
raise HTTPException(status_code=response.status_code, detail="Inventory service error")
except httpx.RequestError as e:
logger.error(f"Could not reach inventory service: {e}")
raise HTTPException(status_code=503, detail="Inventory service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to search equipped items for {character_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/search/upgrades/{character_name}/{slot}")
async def find_equipment_upgrades_proxy(
character_name: str,
slot: int,
upgrade_type: str = Query("damage", description="What to optimize for: damage, armor, workmanship, value")
):
"""Proxy to inventory service equipment upgrades search."""
try:
params = {"upgrade_type": upgrade_type}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{INVENTORY_SERVICE_URL}/search/upgrades/{character_name}/{slot}",
params=params
)
if response.status_code == 200:
return JSONResponse(content=response.json())
elif response.status_code == 404:
raise HTTPException(status_code=404, detail=f"No upgrade options found for character '{character_name}' slot {slot}")
else:
logger.error(f"Inventory service returned {response.status_code} for upgrades search")
raise HTTPException(status_code=response.status_code, detail="Inventory service error")
except httpx.RequestError as e:
logger.error(f"Could not reach inventory service: {e}")
raise HTTPException(status_code=503, detail="Inventory service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to find equipment upgrades for {character_name} slot {slot}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/sets/list")
async def list_equipment_sets_proxy():
"""Proxy to inventory service equipment sets list."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{INVENTORY_SERVICE_URL}/sets/list")
if response.status_code == 200:
return JSONResponse(content=response.json())
else:
logger.error(f"Inventory service returned {response.status_code} for sets list")
raise HTTPException(status_code=response.status_code, detail="Inventory service error")
except httpx.RequestError as e:
logger.error(f"Could not reach inventory service: {e}")
raise HTTPException(status_code=503, detail="Inventory service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list equipment sets: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# -------------------- WebSocket endpoints -----------------------
## WebSocket connection tracking
# Set of browser WebSocket clients subscribed to live updates
@ -994,6 +1276,38 @@ async def serve_icon(icon_filename: str):
# Icon not found
raise HTTPException(status_code=404, detail="Icon not found")
# -------------------- Inventory Service Proxy ---------------------------
@app.get("/inv/test")
async def test_inventory_route():
"""Test route to verify inventory proxy is working"""
return {"message": "Inventory proxy route is working"}
@app.api_route("/inv/{path:path}", methods=["GET", "POST"])
async def proxy_inventory_service(path: str, request: Request):
"""Proxy all inventory service requests"""
try:
inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000')
logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}")
# Forward the request to inventory service
async with httpx.AsyncClient() as client:
response = await client.request(
method=request.method,
url=f"{inventory_service_url}/{path}",
params=request.query_params,
headers=dict(request.headers),
content=await request.body()
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers)
)
except Exception as e:
logger.error(f"Failed to proxy inventory request: {e}")
raise HTTPException(status_code=500, detail="Inventory service unavailable")
# Icons are now served from static/icons directory
# Serve SPA files (catch-all for frontend routes)
# Mount the single-page application frontend (static assets) at root path