diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 74f1e340..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,154 +0,0 @@ -# AGENTS.md - -Guidance for coding agents working in `MosswartOverlord` (Dereth Tracker). - -Read shared integration rules first: `../AGENTS.md`. - -## Scope and priorities - -- This repo is a Python/FastAPI multi-service project with Docker-first workflows. -- Primary services: `main.py` (telemetry API + WS + static frontend), `inventory-service/main.py` (inventory + suitbuilder), `discord-rare-monitor/discord_rare_monitor.py` (Discord bot). -- Favor minimal, targeted changes over broad refactors. - -## Local rule sources - -- Additional project guidance exists in `CLAUDE.md`; follow it when relevant. -- Cursor/Copilot rule discovery is documented centrally in `../AGENTS.md`. - -## Environment and dependencies - -- Python versions in Dockerfiles: 3.12 (main + bot), 3.11 (inventory-service). -- Databases: PostgreSQL/TimescaleDB for telemetry; PostgreSQL for inventory. -- Core Python deps: FastAPI, Uvicorn, SQLAlchemy, databases, asyncpg, httpx. -- Bot deps: `discord.py`, `websockets`. - -## Build and run commands - -## Docker (recommended) - -- Start all services: `docker compose up -d` -- Rebuild app service after source changes (no cache): `docker compose build --no-cache dereth-tracker` -- Redeploy app service: `docker compose up -d dereth-tracker` -- Rebuild inventory service: `docker compose build --no-cache inventory-service` -- Rebuild Discord bot: `docker compose build --no-cache discord-rare-monitor` -- Follow logs (app): `docker logs mosswartoverlord-dereth-tracker-1` -- Follow logs (telemetry DB): `docker logs dereth-db` - -## Local (without Docker) - -- Main API dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8765` -- Inventory service dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8000` (from `inventory-service/`) -- Data generator: `python generate_data.py` -- Discord bot run: `python discord-rare-monitor/discord_rare_monitor.py` - -## Lint/format commands - -- Repo formatter target: `make reformat` -- What it does: runs `black *.py` in repo root. -- Prefer formatting changed files before finalizing edits. -- No repo-level Ruff/Flake8/isort/mypy config files were found. - -## Test commands - -- There is no conventional `tests/` suite configured in this repo. -- Existing executable test script: `python discord-rare-monitor/test_websocket.py` -- This script validates rare classification and WebSocket handling. -- It expects a reachable server at `ws://localhost:8765/ws/position` for connection checks. - -## Single-test guidance (important) - -- For the current codebase, a single targeted test means running the script above. -- Practical single-test command: -- `python discord-rare-monitor/test_websocket.py` -- The script is not pytest-based; use stdout/log output for pass/fail interpretation. -- If pytest is introduced later, preferred pattern is: -- `python -m pytest path/to/test_file.py::test_name -q` - -## Service-specific quick checks - -- Main health endpoint: `GET /debug` -- Live data endpoint: `GET /live` -- History endpoint: `GET /history` -- Plugin WS endpoint: `/ws/position` (authenticated) -- Browser WS endpoint: `/ws/live` (unauthenticated) -- Inventory service endpoint family: `/search/*`, `/inventory/*`, `/suitbuilder/*` - -## Repo-specific architecture notes - -- Telemetry DB schema is in `db_async.py` (SQLAlchemy Core tables). -- Inventory DB schema is in `inventory-service/database.py` (SQLAlchemy ORM models). -- Static frontend is served from `static/` by FastAPI. -- Keep inventory-service enum loading paths intact (`comprehensive_enum_database_v2.json`, fallback JSON). - -## Code style conventions observed - -## Imports and module structure - -- Use standard-library imports first, then third-party, then local imports. -- Keep import groups separated by one blank line. -- Prefer explicit imports over wildcard imports. -- In existing files, `typing` imports are common (`Dict`, `List`, `Optional`, `Any`). -- Avoid introducing circular imports; shared helpers belong in dedicated modules. - -## Formatting and layout - -- Follow Black-compatible formatting (88-char style assumptions are acceptable). -- Use 4 spaces, no tabs. -- Keep functions focused; extract helpers for repeated logic. -- Maintain existing docstring style (triple double quotes for module/function docs). -- Preserve readable logging statements with context-rich messages. - -## Types and data models - -- Add type hints for new functions and non-trivial variables. -- Use Pydantic models for request/response payload validation in FastAPI layers. -- Keep DB schema changes explicit in SQLAlchemy model/table definitions. -- Prefer precise types over `Any` when practical. -- For optional values, use `Optional[T]` or `T | None` consistently within a file. - -## Naming conventions - -- Functions/variables: `snake_case`. -- Classes: `PascalCase`. -- Constants/env names: `UPPER_SNAKE_CASE`. -- Endpoint handlers should be action-oriented and descriptive. -- Database table/column names should remain stable unless migration is planned. - -## Error handling and resilience - -- Prefer explicit `try/except` around external I/O boundaries: -- DB calls, WebSocket send/recv, HTTP calls, file I/O, JSON parsing. -- Log actionable errors with enough context to debug production issues. -- Fail gracefully for transient network/database errors (retry where already patterned). -- Do not swallow exceptions silently; at minimum log at `warning` or `error`. -- Keep user-facing APIs predictable (consistent JSON error responses). - -## Logging conventions - -- Use module-level logger: `logger = logging.getLogger(__name__)`. -- Respect `LOG_LEVEL` environment variable patterns already present. -- Prefer structured, concise messages; avoid noisy logs in hot loops. -- Keep emoji-heavy logging style only where already established in file context. - -## Database and migrations guidance - -- Be careful with uniqueness/index assumptions (especially portal coordinate rounding logic). -- Validate any schema-affecting changes against Dockerized Postgres services. - -## Frontend/static guidance - -- Preserve existing API base path assumptions used by frontend scripts. -- Reverse-proxy prefix behavior (`/api`) is documented in `../AGENTS.md`; keep frontend/backend paths aligned. - -## Secrets and configuration - -- Never hardcode secrets/tokens in commits. -- Use env vars (`SHARED_SECRET`, `POSTGRES_PASSWORD`, bot token variables). -- Keep defaults safe for local dev, not production credentials. - -## Change management for agents - -- Keep patches small and scoped to the requested task. -- Update docs when behavior, endpoints, or run commands change. -- If adding new tooling (pytest/ruff/mypy), include config and command docs in this file. -- For cross-repo payload changes, follow `../AGENTS.md` checklist and update both sides. diff --git a/docs/suitbuilder.md b/docs/suitbuilder.md deleted file mode 100644 index 9a831346..00000000 --- a/docs/suitbuilder.md +++ /dev/null @@ -1,219 +0,0 @@ -# Suitbuilder Algorithm - -The suitbuilder finds optimal equipment loadouts across multiple characters' inventories. It fills 17 equipment slots (9 armor, 6 jewelry, 2 clothing) using a constraint satisfaction solver with depth-first search and branch pruning. - -## Search Pipeline - -The search runs in 5 phases, streamed to the browser via SSE: - -1. **Load items** - Fetch from inventory API (armor by set, jewelry by slot type, clothing DR3-only) -2. **Create buckets** - Group items into 17 slot buckets, expand multi-slot items -3. **Apply reductions** - Generate tailored variants of multi-coverage armor pieces -4. **Sort buckets** - Order buckets and items within them for optimal pruning -5. **Recursive search** - Depth-first search with backtracking, streaming top 10 results - -## Item Loading - -Items are fetched from the internal inventory API (`localhost:8000/search/items`) in four batches: - -| Batch | Filter | Notes | -|-------|--------|-------| -| Primary set armor | `item_set={name}` | All armor in user's primary set | -| Secondary set armor | `item_set={name}` | All armor in user's secondary set | -| Clothing | `shirt_only` / `pants_only` | Only DR3+ shirts and pants | -| Jewelry | `jewelry_only` + `slot_names={type}` | Rings, bracelets, necklaces, trinkets separately | - -After loading, a **domination pre-filter** removes items that are strictly worse than another item in the same slot with the same set. Item A is "surpassed" by item B when B has equal-or-better spells (Legendary > Epic > Major), equal-or-better ratings, equal-or-better armor, and is strictly better in at least one category. - -## Bucket Creation - -Each of the 17 slots gets a bucket. Items are assigned to buckets with special handling: - -- **Multi-slot items** (e.g., "Left Wrist, Right Wrist") are cloned into each applicable slot bucket -- **Generic jewelry** ("Ring" -> Left Ring + Right Ring, "Bracelet" -> Left Wrist + Right Wrist) -- **Robes** (6+ coverage areas) are excluded entirely - they can't be reduced to single slots - -All 17 buckets are created even if empty, allowing the search to produce incomplete suits when no valid item exists for a slot. - -## Armor Reduction (Tailoring) - -Multi-coverage armor can be tailored to fit a single slot. Only loot-generated items (those with a `material`) are eligible. Reduction patterns follow Mag-SuitBuilder logic: - -| Original Coverage | Reduces To | -|---|---| -| Upper Arms + Lower Arms | Upper Arms **or** Lower Arms | -| Upper Legs + Lower Legs | Upper Legs **or** Lower Legs | -| Lower Legs + Feet | Feet | -| Chest + Abdomen | Chest | -| Chest + Abdomen + Upper Arms | Chest | -| Chest + Upper Arms + Lower Arms | Chest | -| Chest + Upper Arms | Chest | -| Abdomen + Upper Legs + Lower Legs | Abdomen **or** Upper Legs **or** Lower Legs | -| Chest + Abdomen + Upper Arms + Lower Arms (hauberks) | Chest | -| Abdomen + Upper Legs | Abdomen | - -Reduced items are added to the target slot's bucket as `"Item Name (tailored to Slot)"`. - -## Bucket Sort Order - -### Bucket ordering (which slot to fill first) - -Buckets are searched in this priority: - -1. **Core armor** - Chest, Head, Hands, Feet, Upper Arms, Lower Arms, Abdomen, Upper Legs, Lower Legs -2. **Jewelry** - Neck, Left Ring, Right Ring, Left Wrist, Right Wrist, Trinket -3. **Clothing** - Shirt, Pants - -Within each category, buckets are further sorted by their position in the priority list (not by item count). This means armor slots are always filled before jewelry, and jewelry before clothing. - -### Item ordering within each bucket - -Items within a bucket are sorted to try the best candidates first. The sort depends on slot type: - -| Slot Type | Sort Priority (highest first) | -|-----------|-------------------------------| -| **Armor** | User's primary set > secondary set > others, then crit damage rating desc, then damage rating desc, then armor level desc | -| **Jewelry** | Spell count desc, then total ratings desc | -| **Clothing** (Shirt/Pants) | Damage rating desc, then spell count desc, then other ratings desc | - -All sorts include `(character_name, name)` as final tiebreakers for deterministic results. - -## Recursive Search - -The solver uses depth-first search with backtracking across the ordered buckets: - -``` -for each bucket (slot) in order: - for each item in bucket: - if item passes constraints: - add item to suit state - recurse to next bucket - remove item (backtrack) - if no items were accepted: - skip this slot (allow incomplete suits) - recurse to next bucket -``` - -When all buckets are processed, the suit is scored and kept if it ranks in the top N (default 10). - -### Branch Pruning - -Two pruning strategies cut off hopeless branches early: - -1. **Mag-SuitBuilder style**: If `current_items + 1 < highest_armor_count_seen - remaining_armor_buckets`, prune. This ensures we don't explore branches that can't produce suits with enough armor pieces. - -2. **Max-items pruning**: If `current_items + remaining_buckets < best_suit_item_count`, prune. The branch can't produce a suit with more items than the best found so far. - -### Item Acceptance Rules (`can_add_item`) - -An item must pass all of these checks: - -1. **Slot available** - The slot must not already be occupied in the current suit state -2. **Item uniqueness** - The same physical item (by ID) can't appear in multiple slots -3. **Set membership** (armor only): - - Primary set items: accepted up to effective limit (5 minus locked primary pieces) - - Secondary set items: accepted up to effective limit (4 minus locked secondary pieces) - - Other set items: **rejected** for armor slots, allowed for jewelry only if they contribute required spells - - No-set items: **rejected** for armor, allowed for clothing always, allowed for jewelry only if they contribute required spells -4. **Spell contribution** (when required spells are specified): - - Items with spells must contribute at least one **new** required spell not already covered by the current suit - - Items where all spells are duplicates of already-covered spells are **rejected**, even from the target sets - - Jewelry has an additional gate: it must contribute an uncovered required spell or it's rejected (empty slot preferred over useless jewelry) - -### Locked Slots - -Users can lock specific slots with a predetermined set and/or spells. Locked slots are: -- Removed from the bucket list (not searched) -- Their set contributions are subtracted from set requirements (e.g., 2 locked primary pieces means only 3 more needed) -- Their spells are counted as already fulfilled - -## Scoring - -The scoring system determines suit ranking. Points are awarded in this priority order: - -### 1. Set Completion (highest weight) - -| Condition | Points | -|-----------|--------| -| Primary set complete (found pieces >= effective need) | **+1000** | -| Secondary set complete | **+1000** | -| Missing primary piece | **-200** per missing piece | -| Missing secondary piece | **-200** per missing piece | -| Excess primary pieces (beyond 5) | **-500** per excess piece | -| Excess secondary pieces (beyond 4) | **-500** per excess piece | - -### 2. Crit Damage Rating (armor pieces) - -| Rating | Points | -|--------|--------| -| CD1 (crit_damage_rating = 1) | **+10** per piece | -| CD2 (crit_damage_rating = 2) | **+20** per piece | - -### 3. Damage Rating (clothing only - Shirt/Pants) - -| Rating | Points | -|--------|--------| -| DR1 | **+10** per piece | -| DR2 | **+20** per piece | -| DR3 | **+30** per piece | - -### 4. Spell Coverage - -| Condition | Points | -|-----------|--------| -| Each fulfilled required spell | **+100** | - -### 5. Base Item Score - -| Condition | Points | -|-----------|--------| -| Each item in the suit | **+5** | - -### 6. Armor Level (tiebreaker only) - -| Condition | Points | -|-----------|--------| -| Total armor level | **+1 per 100 AL** (e.g., 4500 AL = +45) | - -Score is floored at 0 (never negative). - -### Practical Effect of Scoring Weights - -The weights create this effective priority: - -1. **Complete sets matter most** - A suit with both sets complete (+2000) always beats one with a missing piece, regardless of other stats -2. **Spells matter second** - Each required cantrip/ward is worth +100, so 10 spells = +1000 (equivalent to one complete set) -3. **Crit damage and damage rating are tiebreakers** - CD2 on all 9 armor pieces = +180, DR3 on both clothes = +60 -4. **Armor level barely matters** - Only ~45 points for a full suit of 4500 AL; it only breaks ties between otherwise-equal suits - -## Frontend Display - -Results stream in as SSE events. The frontend maintains a sorted list of top 10 suits: - -- New suits are inserted in score-ordered position (highest first) -- If the list is full (10 suits) and the new suit scores lower than all existing ones, it's discarded -- Medals are assigned by position: gold/silver/bronze for top 3 - -### Score Display Classes - -| Score Range | CSS Class | -|-------------|-----------| -| >= 90 | `excellent` | -| >= 75 | `good` | -| >= 60 | `fair` | -| < 60 | `poor` | - -### Item Display - -Each suit shows a table with all 17 slots. Per item: -- **Armor pieces**: Show CD (crit damage) and CDR (crit damage resist) ratings -- **Clothing pieces**: Show DR (damage rating) and DRR (damage resist rating) -- **Spells**: Show up to 2 Legendary/Epic spells, then "+N more" -- **Multi-slot items** that need tailoring are marked with an asterisk (*) - -### Suit Selection - -Clicking a suit populates the right-panel equipment slots visual. Users can then: -- Lock slots (preserving set/spell info for re-searches) -- Copy suit summary to clipboard -- Clear individual slots diff --git a/inventory-service/main.py b/inventory-service/main.py index 14e11ad1..400f5866 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -5,7 +5,6 @@ Handles enum translation, data normalization, and provides structured item data """ import json -import math import time import logging from pathlib import Path @@ -21,16 +20,8 @@ import databases import sqlalchemy as sa from database import ( - Base, - Item, - ItemCombatStats, - ItemRequirements, - ItemEnhancements, - ItemRatings, - ItemSpells, - ItemRawData, - DATABASE_URL, - create_indexes, + Base, Item, ItemCombatStats, ItemRequirements, ItemEnhancements, + ItemRatings, ItemSpells, ItemRawData, DATABASE_URL, create_indexes ) # Import helpers to share enum mappings @@ -43,7 +34,6 @@ logger = logging.getLogger(__name__) # Import suitbuilder router - after logger is defined try: from suitbuilder import router as suitbuilder_router, set_database_connection - SUITBUILDER_AVAILABLE = True logger.info("Suitbuilder module imported successfully") except ImportError as e: @@ -113,7 +103,6 @@ if SUITBUILDER_AVAILABLE: database = databases.Database(DATABASE_URL) engine = sa.create_engine(DATABASE_URL) - # Load comprehensive enum mappings def load_comprehensive_enums(): """Load complete enum database with all translations.""" @@ -121,176 +110,151 @@ def load_comprehensive_enums(): try: # Try new comprehensive database first logger.info("Attempting to load comprehensive_enum_database_v2.json") - with open("comprehensive_enum_database_v2.json", "r") as f: + with open('comprehensive_enum_database_v2.json', 'r') as f: enum_db = json.load(f) logger.info("Successfully loaded comprehensive_enum_database_v2.json") except FileNotFoundError: logger.warning("comprehensive_enum_database_v2.json not found, trying fallback") try: - with open("complete_enum_database.json", "r") as f: + with open('complete_enum_database.json', 'r') as f: enum_db = json.load(f) logger.info("Successfully loaded complete_enum_database.json") except FileNotFoundError as e: logger.error(f"No enum database found: {e}") - return { - "int_values": {}, - "materials": {}, - "item_types": {}, - "skills": {}, - "spell_categories": {}, - "spells": {}, - "object_classes": {}, - "coverage_masks": {}, - } + return {'int_values': {}, 'materials': {}, 'item_types': {}, 'skills': {}, 'spell_categories': {}, 'spells': {}, 'object_classes': {}, 'coverage_masks': {}} except Exception as e: logger.error(f"Error reading enum database file: {e}") - return { - "int_values": {}, - "materials": {}, - "item_types": {}, - "skills": {}, - "spell_categories": {}, - "spells": {}, - "object_classes": {}, - "coverage_masks": {}, - } - + return {'int_values': {}, 'materials': {}, 'item_types': {}, 'skills': {}, 'spell_categories': {}, 'spells': {}, 'object_classes': {}, 'coverage_masks': {}} + # Extract specific enum mappings for easy access from new format logger.info("Processing loaded enum database...") - enums = enum_db.get("enums", {}) - spells_data = enum_db.get("spells", {}) - object_classes_data = enum_db.get("object_classes", {}) - + enums = enum_db.get('enums', {}) + spells_data = enum_db.get('spells', {}) + object_classes_data = enum_db.get('object_classes', {}) + # Convert IntValueKey to integer-keyed dict for fast lookup int_values = {} - if "IntValueKey" in enums: - for k, v in enums["IntValueKey"]["values"].items(): + if 'IntValueKey' in enums: + for k, v in enums['IntValueKey']['values'].items(): try: int_values[int(k)] = v except (ValueError, TypeError): pass # Skip non-numeric keys - + # Material types mapping materials = {} - if "MaterialType" in enums: - for k, v in enums["MaterialType"]["values"].items(): + if 'MaterialType' in enums: + for k, v in enums['MaterialType']['values'].items(): try: materials[int(k)] = v except (ValueError, TypeError): pass - - # Item types mapping + + # Item types mapping item_types = {} - if "ItemType" in enums: - for k, v in enums["ItemType"]["values"].items(): + if 'ItemType' in enums: + for k, v in enums['ItemType']['values'].items(): try: item_types[int(k)] = v except (ValueError, TypeError): pass - + # Skills mapping skills = {} - if "Skill" in enums: - skill_data = enums["Skill"]["values"] + if 'Skill' in enums: + skill_data = enums['Skill']['values'] for k, v in skill_data.items(): try: if isinstance(v, dict): - skills[int(k)] = v.get("name", str(v)) + skills[int(k)] = v.get('name', str(v)) else: skills[int(k)] = str(v) except (ValueError, TypeError): pass - + # Spell categories mapping spell_categories = {} - if "SpellCategory" in enums: - for k, v in enums["SpellCategory"]["values"].items(): + if 'SpellCategory' in enums: + for k, v in enums['SpellCategory']['values'].items(): try: spell_categories[int(k)] = v except (ValueError, TypeError): pass - + # Coverage mask mapping coverage_masks = {} - if "CoverageMask" in enums: - for k, v in enums["CoverageMask"]["values"].items(): + if 'CoverageMask' in enums: + for k, v in enums['CoverageMask']['values'].items(): try: coverage_masks[int(k)] = v except (ValueError, TypeError): pass - + # Object classes mapping object_classes = {} - if object_classes_data and "values" in object_classes_data: - for k, v in object_classes_data["values"].items(): + if object_classes_data and 'values' in object_classes_data: + for k, v in object_classes_data['values'].items(): try: object_classes[int(k)] = v except (ValueError, TypeError): pass - + # Spells mapping spells = {} - if spells_data and "values" in spells_data: - spells = {int(k): v for k, v in spells_data["values"].items() if k.isdigit()} - + if spells_data and 'values' in spells_data: + spells = {int(k): v for k, v in spells_data['values'].items() if k.isdigit()} + # AttributeSetInfo - Equipment Set Names (CRITICAL for armor set detection) attribute_sets = {} # Check in dictionaries section first, then enums as fallback - if "dictionaries" in enum_db and "AttributeSetInfo" in enum_db["dictionaries"]: - for k, v in enum_db["dictionaries"]["AttributeSetInfo"]["values"].items(): + if 'dictionaries' in enum_db and 'AttributeSetInfo' in enum_db['dictionaries']: + for k, v in enum_db['dictionaries']['AttributeSetInfo']['values'].items(): attribute_sets[k] = v # String key try: attribute_sets[int(k)] = v # Also int key except (ValueError, TypeError): pass - elif "AttributeSetInfo" in enums: - for k, v in enums["AttributeSetInfo"]["values"].items(): + elif 'AttributeSetInfo' in enums: + for k, v in enums['AttributeSetInfo']['values'].items(): attribute_sets[k] = v # String key try: attribute_sets[int(k)] = v # Also int key except (ValueError, TypeError): pass - - logger.info( - f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes, {len(attribute_sets)} equipment sets" - ) - + + logger.info(f"Enum database loaded successfully: {len(int_values)} int values, {len(spells)} spells, {len(object_classes)} object classes, {len(attribute_sets)} equipment sets") + return { - "int_values": int_values, - "materials": materials, - "item_types": item_types, - "skills": skills, - "spell_categories": spell_categories, - "coverage_masks": coverage_masks, - "object_classes": object_classes, - "spells": spells, - "equipment_sets": attribute_sets, # Backward compatibility - "dictionaries": {"AttributeSetInfo": {"values": attribute_sets}}, - "AttributeSetInfo": { - "values": attribute_sets - }, # Also add in the format expected by the endpoint - "full_database": enum_db, + 'int_values': int_values, + 'materials': materials, + 'item_types': item_types, + 'skills': skills, + 'spell_categories': spell_categories, + 'coverage_masks': coverage_masks, + 'object_classes': object_classes, + 'spells': spells, + 'equipment_sets': attribute_sets, # Backward compatibility + 'dictionaries': { + 'AttributeSetInfo': {'values': attribute_sets} + }, + 'AttributeSetInfo': {'values': attribute_sets}, # Also add in the format expected by the endpoint + 'full_database': enum_db } - 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.""" - character_name: str timestamp: datetime items: List[Dict[str, Any]] - class ProcessedItem(BaseModel): """Processed item with translated properties.""" - name: str icon: int object_class: int @@ -298,18 +262,16 @@ 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": { @@ -322,27 +284,25 @@ class ItemSearchResponse(BaseModel): "set_name": "Soldier's", "material_name": "Gold", "equipped": False, - "spells": ["Legendary Strength", "Legendary Endurance"], + "spells": ["Legendary Strength", "Legendary Endurance"] } ], "total_count": 1247, "page": 1, "limit": 200, "has_next": True, - "has_previous": False, + "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": { @@ -350,98 +310,83 @@ class ProcessingStats(BaseModel): "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", - ], + "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}, + {"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", + "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) # Migrate: add container_id and slot columns if missing (added for live inventory) from sqlalchemy import inspect as sa_inspect - inspector = sa_inspect(engine) - existing_columns = {c["name"] for c in inspector.get_columns("items")} + existing_columns = {c['name'] for c in inspector.get_columns('items')} with engine.begin() as conn: - if "container_id" not in existing_columns: - conn.execute( - sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0") - ) + if 'container_id' not in existing_columns: + conn.execute(sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0")) logger.info("Migration: added container_id column to items table") - if "slot" not in existing_columns: - conn.execute( - sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1") - ) + if 'slot' not in existing_columns: + conn.execute(sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1")) logger.info("Migration: added slot column to items table") # Create performance indexes create_indexes(engine) - + logger.info("Inventory service started successfully") - @app.on_event("shutdown") async def shutdown(): """Close database connection.""" await database.disconnect() - # Enhanced translation functions def translate_int_values(int_values: Dict[str, int]) -> Dict[str, Any]: """Translate IntValues enum keys to human-readable names using comprehensive database.""" translated = {} - int_enum_map = ENUM_MAPPINGS.get("int_values", {}) - + int_enum_map = ENUM_MAPPINGS.get('int_values', {}) + for key_str, value in int_values.items(): try: key_int = int(key_str) @@ -454,41 +399,35 @@ def translate_int_values(int_values: Dict[str, int]) -> Dict[str, Any]: except ValueError: # Skip non-numeric keys translated[key_str] = value - + return translated - def translate_material_type(material_id: int) -> str: """Translate material type ID to human-readable name.""" - materials = ENUM_MAPPINGS.get("materials", {}) + materials = ENUM_MAPPINGS.get('materials', {}) return materials.get(material_id, f"Unknown_Material_{material_id}") - def translate_item_type(item_type_id: int) -> str: """Translate item type ID to human-readable name.""" - item_types = ENUM_MAPPINGS.get("item_types", {}) + item_types = ENUM_MAPPINGS.get('item_types', {}) return item_types.get(item_type_id, f"Unknown_ItemType_{item_type_id}") - -def derive_item_type_from_object_class( - object_class: int, item_data: dict = None -) -> str: +def derive_item_type_from_object_class(object_class: int, item_data: dict = None) -> str: """Derive ItemType from ObjectClass using the object_classes enum.""" # Use the object_classes enum directly for accurate classifications - object_classes = ENUM_MAPPINGS.get("object_classes", {}) + object_classes = ENUM_MAPPINGS.get('object_classes', {}) item_type = object_classes.get(object_class) - + if item_type: return item_type else: # Fallback to "Misc" if ObjectClass not found in enum return "Misc" - def translate_equipment_set_id(set_id: str) -> str: """Translate equipment set ID to set name using comprehensive database.""" - dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) - attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) set_name = attribute_set_info.get(str(set_id)) if set_name: return set_name @@ -496,136 +435,122 @@ def translate_equipment_set_id(set_id: str) -> str: # Fallback to just return the ID as string (matches database storage fallback) return str(set_id) - def translate_object_class(object_class_id: int, item_data: dict = None) -> str: """Translate object class ID to human-readable name with context-aware detection.""" # Use the extracted ObjectClass enum first - object_classes = ENUM_MAPPINGS.get("object_classes", {}) + object_classes = ENUM_MAPPINGS.get('object_classes', {}) if object_class_id in object_classes: base_name = object_classes[object_class_id] - + # Context-aware classification for Gem class (ID 11) if base_name == "Gem" and object_class_id == 11 and item_data: # Check item name and properties to distinguish types - item_name = item_data.get("Name", "").lower() - + item_name = item_data.get('Name', '').lower() + # Mana stones and crystals - if any( - keyword in item_name for keyword in ["mana stone", "crystal", "gem"] - ): - if "mana stone" in item_name: + if any(keyword in item_name for keyword in ['mana stone', 'crystal', 'gem']): + if 'mana stone' in item_name: return "Mana Stone" - elif "crystal" in item_name: + elif 'crystal' in item_name: return "Crystal" else: return "Gem" - + # Aetheria detection - check for specific properties - int_values = item_data.get("IntValues", {}) + int_values = item_data.get('IntValues', {}) if isinstance(int_values, dict): # Check for Aetheria-specific properties - has_item_set = ( - "265" in int_values or 265 in int_values - ) # EquipmentSetId - has_aetheria_level = ( - "218103840" in int_values or 218103840 in int_values - ) # ItemMaxLevel - - if has_item_set or has_aetheria_level or "aetheria" in item_name: + has_item_set = '265' in int_values or 265 in int_values # EquipmentSetId + has_aetheria_level = '218103840' in int_values or 218103840 in int_values # ItemMaxLevel + + if has_item_set or has_aetheria_level or 'aetheria' in item_name: return "Aetheria" - + # Default to Gem for other items in this class return "Gem" - + return base_name - + # Fallback to WeenieType enum weenie_types = {} - if "WeenieType" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): - weenie_data = ENUM_MAPPINGS["full_database"]["enums"]["WeenieType"]["values"] + if 'WeenieType' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): + weenie_data = ENUM_MAPPINGS['full_database']['enums']['WeenieType']['values'] for k, v in weenie_data.items(): try: weenie_types[int(k)] = v except (ValueError, TypeError): pass - + return weenie_types.get(object_class_id, f"Unknown_ObjectClass_{object_class_id}") - def translate_skill(skill_id: int) -> str: """Translate skill ID to skill name.""" - skills = ENUM_MAPPINGS.get("skills", {}) + skills = ENUM_MAPPINGS.get('skills', {}) return skills.get(skill_id, f"Unknown_Skill_{skill_id}") - def translate_spell_category(category_id: int) -> str: """Translate spell category ID to spell category name.""" - spell_categories = ENUM_MAPPINGS.get("spell_categories", {}) + spell_categories = ENUM_MAPPINGS.get('spell_categories', {}) return spell_categories.get(category_id, f"Unknown_SpellCategory_{category_id}") - def translate_spell(spell_id: int) -> Dict[str, Any]: """Translate spell ID to spell data including name, description, school, etc.""" - spells = ENUM_MAPPINGS.get("spells", {}) + spells = ENUM_MAPPINGS.get('spells', {}) spell_data = spells.get(spell_id) - + if spell_data: return { - "id": spell_id, - "name": spell_data.get("name", f"Unknown_Spell_{spell_id}"), - "description": spell_data.get("description", ""), - "school": spell_data.get("school", ""), - "difficulty": spell_data.get("difficulty", ""), - "duration": spell_data.get("duration", ""), - "mana": spell_data.get("mana", ""), - "family": spell_data.get("family", ""), + 'id': spell_id, + 'name': spell_data.get('name', f'Unknown_Spell_{spell_id}'), + 'description': spell_data.get('description', ''), + 'school': spell_data.get('school', ''), + 'difficulty': spell_data.get('difficulty', ''), + 'duration': spell_data.get('duration', ''), + 'mana': spell_data.get('mana', ''), + 'family': spell_data.get('family', '') } else: return { - "id": spell_id, - "name": f"Unknown_Spell_{spell_id}", - "description": "", - "school": "", - "difficulty": "", - "duration": "", - "mana": "", - "family": "", + 'id': spell_id, + 'name': f'Unknown_Spell_{spell_id}', + 'description': '', + 'school': '', + 'difficulty': '', + 'duration': '', + 'mana': '', + 'family': '' } - def translate_coverage_mask(coverage_value: int) -> List[str]: """Translate coverage mask value to list of body parts covered.""" - coverage_masks = ENUM_MAPPINGS.get("coverage_masks", {}) + coverage_masks = ENUM_MAPPINGS.get('coverage_masks', {}) covered_parts = [] - + # Coverage masks are flags, so we need to check each bit for mask_value, part_name in coverage_masks.items(): if coverage_value & mask_value: # Convert technical names to display names - display_name = ( - part_name.replace("Outerwear", "").replace("Underwear", "").strip() - ) + display_name = part_name.replace('Outerwear', '').replace('Underwear', '').strip() if display_name and display_name not in covered_parts: covered_parts.append(display_name) - + # Map technical names to user-friendly names name_mapping = { - "UpperLegs": "Upper Legs", - "LowerLegs": "Lower Legs", - "UpperArms": "Upper Arms", - "LowerArms": "Lower Arms", - "Abdomen": "Abdomen", - "Chest": "Chest", - "Head": "Head", - "Hands": "Hands", - "Feet": "Feet", - "Cloak": "Cloak", - "Robe": "Robe", + 'UpperLegs': 'Upper Legs', + 'LowerLegs': 'Lower Legs', + 'UpperArms': 'Upper Arms', + 'LowerArms': 'Lower Arms', + 'Abdomen': 'Abdomen', + 'Chest': 'Chest', + 'Head': 'Head', + 'Hands': 'Hands', + 'Feet': 'Feet', + 'Cloak': 'Cloak', + 'Robe': 'Robe' } - + return [name_mapping.get(part, part) for part in covered_parts if part] - def get_total_bits_set(value: int) -> int: """Count the number of bits set in a value.""" bits_set = 0 @@ -635,39 +560,34 @@ def get_total_bits_set(value: int) -> int: value >>= 1 return bits_set - def is_body_armor_equip_mask(value: int) -> bool: """Check if EquipMask value represents body armor.""" return (value & 0x00007F21) != 0 - def is_body_armor_coverage_mask(value: int) -> bool: """Check if CoverageMask value represents body armor.""" return (value & 0x0001FF00) != 0 - def get_coverage_reduction_options(coverage_value: int) -> list: """ Get the reduction options for a coverage mask, based on Mag-SuitBuilder logic. This determines which individual slots a multi-slot armor piece can be tailored to fit. """ # CoverageMask values from Mag-Plugins - OuterwearUpperArms = 4096 # 0x00001000 - OuterwearLowerArms = 8192 # 0x00002000 - OuterwearUpperLegs = 256 # 0x00000100 - OuterwearLowerLegs = 512 # 0x00000200 - OuterwearChest = 1024 # 0x00000400 - OuterwearAbdomen = 2048 # 0x00000800 - Head = 16384 # 0x00004000 - Hands = 32768 # 0x00008000 - Feet = 65536 # 0x00010000 - + OuterwearUpperArms = 4096 # 0x00001000 + OuterwearLowerArms = 8192 # 0x00002000 + OuterwearUpperLegs = 256 # 0x00000100 + OuterwearLowerLegs = 512 # 0x00000200 + OuterwearChest = 1024 # 0x00000400 + OuterwearAbdomen = 2048 # 0x00000800 + Head = 16384 # 0x00004000 + Hands = 32768 # 0x00008000 + Feet = 65536 # 0x00010000 + options = [] - + # If single bit or not body armor, return as-is - if get_total_bits_set(coverage_value) <= 1 or not is_body_armor_coverage_mask( - coverage_value - ): + if get_total_bits_set(coverage_value) <= 1 or not is_body_armor_coverage_mask(coverage_value): options.append(coverage_value) else: # Implement Mag-SuitBuilder reduction logic @@ -681,19 +601,13 @@ def get_coverage_reduction_options(coverage_value: int) -> list: options.append(OuterwearChest) elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms): options.append(OuterwearChest) - elif coverage_value == ( - OuterwearChest | OuterwearUpperArms | OuterwearLowerArms - ): + elif coverage_value == (OuterwearChest | OuterwearUpperArms | OuterwearLowerArms): options.append(OuterwearChest) elif coverage_value == (OuterwearChest | OuterwearUpperArms): options.append(OuterwearChest) - elif coverage_value == ( - OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs - ): + elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs | OuterwearLowerLegs): options.extend([OuterwearAbdomen, OuterwearUpperLegs, OuterwearLowerLegs]) - elif coverage_value == ( - OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms - ): + elif coverage_value == (OuterwearChest | OuterwearAbdomen | OuterwearUpperArms | OuterwearLowerArms): options.append(OuterwearChest) elif coverage_value == (OuterwearAbdomen | OuterwearUpperLegs): # Pre-2010 retail guidelines - assume abdomen reduction only @@ -701,30 +615,26 @@ def get_coverage_reduction_options(coverage_value: int) -> list: else: # If no specific reduction pattern, return original options.append(coverage_value) - + return options - def coverage_to_equip_mask(coverage_value: int) -> int: """Convert a CoverageMask value to its corresponding EquipMask slot.""" # Coverage to EquipMask mapping from Mag-SuitBuilder coverage_to_slot = { - 16384: 1, # Head -> HeadWear - 1024: 512, # OuterwearChest -> ChestArmor - 4096: 2048, # OuterwearUpperArms -> UpperArmArmor - 8192: 4096, # OuterwearLowerArms -> LowerArmArmor - 32768: 32, # Hands -> HandWear - 2048: 1024, # OuterwearAbdomen -> AbdomenArmor - 256: 8192, # OuterwearUpperLegs -> UpperLegArmor - 512: 16384, # OuterwearLowerLegs -> LowerLegArmor - 65536: 256, # Feet -> FootWear + 16384: 1, # Head -> HeadWear + 1024: 512, # OuterwearChest -> ChestArmor + 4096: 2048, # OuterwearUpperArms -> UpperArmArmor + 8192: 4096, # OuterwearLowerArms -> LowerArmArmor + 32768: 32, # Hands -> HandWear + 2048: 1024, # OuterwearAbdomen -> AbdomenArmor + 256: 8192, # OuterwearUpperLegs -> UpperLegArmor + 512: 16384, # OuterwearLowerLegs -> LowerLegArmor + 65536: 256, # Feet -> FootWear } return coverage_to_slot.get(coverage_value, coverage_value) - -def get_sophisticated_slot_options( - equippable_slots: int, coverage_value: int, has_material: bool = True -) -> list: +def get_sophisticated_slot_options(equippable_slots: int, coverage_value: int, has_material: bool = True) -> list: """ Get sophisticated slot options using Mag-SuitBuilder logic. This handles armor reduction for tailorable pieces. @@ -734,12 +644,9 @@ def get_sophisticated_slot_options( FootWear = 256 if equippable_slots == (LowerLegWear | FootWear): return [FootWear] - + # If it's body armor with multiple slots - if ( - is_body_armor_equip_mask(equippable_slots) - and get_total_bits_set(equippable_slots) > 1 - ): + if is_body_armor_equip_mask(equippable_slots) and get_total_bits_set(equippable_slots) > 1: if not has_material: # Can't reduce non-loot gen pieces, return all slots return [equippable_slots] @@ -755,170 +662,165 @@ def get_sophisticated_slot_options( # Single slot or non-armor return [equippable_slots] - def convert_slot_name_to_friendly(slot_name: str) -> str: """Convert technical slot names to user-friendly names.""" name_mapping = { - "HeadWear": "Head", - "ChestWear": "Chest", - "ChestArmor": "Chest", - "AbdomenWear": "Abdomen", - "AbdomenArmor": "Abdomen", - "UpperArmWear": "Upper Arms", - "UpperArmArmor": "Upper Arms", - "LowerArmWear": "Lower Arms", - "LowerArmArmor": "Lower Arms", - "HandWear": "Hands", - "UpperLegWear": "Upper Legs", - "UpperLegArmor": "Upper Legs", - "LowerLegWear": "Lower Legs", - "LowerLegArmor": "Lower Legs", - "FootWear": "Feet", - "NeckWear": "Neck", - "WristWearLeft": "Left Wrist", - "WristWearRight": "Right Wrist", - "FingerWearLeft": "Left Ring", - "FingerWearRight": "Right Ring", - "MeleeWeapon": "Melee Weapon", - "Shield": "Shield", - "MissileWeapon": "Missile Weapon", - "MissileAmmo": "Ammo", - "Held": "Held", - "TwoHanded": "Two-Handed", - "TrinketOne": "Trinket", - "Cloak": "Cloak", - "Robe": "Robe", - "SigilOne": "Aetheria Blue", - "SigilTwo": "Aetheria Yellow", - "SigilThree": "Aetheria Red", + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak', + 'Robe': 'Robe', + 'SigilOne': 'Aetheria Blue', + 'SigilTwo': 'Aetheria Yellow', + 'SigilThree': 'Aetheria Red' } return name_mapping.get(slot_name, slot_name) - def translate_equipment_slot(wielded_location: int) -> str: """Translate equipment slot mask to human-readable slot name(s), handling bit flags.""" if wielded_location == 0: return "Inventory" - + # Get EquipMask enum from database equip_mask_map = {} - if "EquipMask" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): - equip_data = ENUM_MAPPINGS["full_database"]["enums"]["EquipMask"]["values"] + if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): + equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] for k, v in equip_data.items(): try: # Skip expression values (EXPR:...) - if not k.startswith("EXPR:"): + if not k.startswith('EXPR:'): equip_mask_map[int(k)] = v except (ValueError, TypeError): pass - + # Check for exact match first if wielded_location in equip_mask_map: slot_name = equip_mask_map[wielded_location] # Convert technical names to user-friendly names name_mapping = { - "HeadWear": "Head", - "ChestWear": "Chest", - "ChestArmor": "Chest", - "AbdomenWear": "Abdomen", - "AbdomenArmor": "Abdomen", - "UpperArmWear": "Upper Arms", - "UpperArmArmor": "Upper Arms", - "LowerArmWear": "Lower Arms", - "LowerArmArmor": "Lower Arms", - "HandWear": "Hands", - "UpperLegWear": "Upper Legs", - "UpperLegArmor": "Upper Legs", - "LowerLegWear": "Lower Legs", - "LowerLegArmor": "Lower Legs", - "FootWear": "Feet", - "NeckWear": "Neck", - "WristWearLeft": "Left Wrist", - "WristWearRight": "Right Wrist", - "FingerWearLeft": "Left Ring", - "FingerWearRight": "Right Ring", - "MeleeWeapon": "Melee Weapon", - "Shield": "Shield", - "MissileWeapon": "Missile Weapon", - "MissileAmmo": "Ammo", - "Held": "Held", - "TwoHanded": "Two-Handed", - "TrinketOne": "Trinket", - "Cloak": "Cloak", - "Robe": "Robe", + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak', + 'Robe': 'Robe' } return name_mapping.get(slot_name, slot_name) - + # Handle common equipment slots that may be missing from enum database common_slots = { 30: "Shirt", # ChestWear + AbdomenWear + UpperArmWear + LowerArmWear for shirts 786432: "Left Ring, Right Ring", # 262144 + 524288 for rings that can go in either slot 262144: "Left Ring", - 524288: "Right Ring", + 524288: "Right Ring" } - + if wielded_location in common_slots: return common_slots[wielded_location] - + # If no exact match, decode bit flags slot_parts = [] for mask_value, slot_name in equip_mask_map.items(): if mask_value > 0 and (wielded_location & mask_value) == mask_value: slot_parts.append(slot_name) - + if slot_parts: # Convert technical names to user-friendly names name_mapping = { - "HeadWear": "Head", - "ChestWear": "Chest", - "ChestArmor": "Chest", - "AbdomenWear": "Abdomen", - "AbdomenArmor": "Abdomen", - "UpperArmWear": "Upper Arms", - "UpperArmArmor": "Upper Arms", - "LowerArmWear": "Lower Arms", - "LowerArmArmor": "Lower Arms", - "HandWear": "Hands", - "UpperLegWear": "Upper Legs", - "UpperLegArmor": "Upper Legs", - "LowerLegWear": "Lower Legs", - "LowerLegArmor": "Lower Legs", - "FootWear": "Feet", - "NeckWear": "Neck", - "WristWearLeft": "Left Wrist", - "WristWearRight": "Right Wrist", - "FingerWearLeft": "Left Ring", - "FingerWearRight": "Right Ring", - "MeleeWeapon": "Melee Weapon", - "Shield": "Shield", - "MissileWeapon": "Missile Weapon", - "MissileAmmo": "Ammo", - "Held": "Held", - "TwoHanded": "Two-Handed", - "TrinketOne": "Trinket", - "Cloak": "Cloak", - "Robe": "Robe", + 'HeadWear': 'Head', + 'ChestWear': 'Chest', + 'ChestArmor': 'Chest', + 'AbdomenWear': 'Abdomen', + 'AbdomenArmor': 'Abdomen', + 'UpperArmWear': 'Upper Arms', + 'UpperArmArmor': 'Upper Arms', + 'LowerArmWear': 'Lower Arms', + 'LowerArmArmor': 'Lower Arms', + 'HandWear': 'Hands', + 'UpperLegWear': 'Upper Legs', + 'UpperLegArmor': 'Upper Legs', + 'LowerLegWear': 'Lower Legs', + 'LowerLegArmor': 'Lower Legs', + 'FootWear': 'Feet', + 'NeckWear': 'Neck', + 'WristWearLeft': 'Left Wrist', + 'WristWearRight': 'Right Wrist', + 'FingerWearLeft': 'Left Ring', + 'FingerWearRight': 'Right Ring', + 'MeleeWeapon': 'Melee Weapon', + 'Shield': 'Shield', + 'MissileWeapon': 'Missile Weapon', + 'MissileAmmo': 'Ammo', + 'Held': 'Held', + 'TwoHanded': 'Two-Handed', + 'TrinketOne': 'Trinket', + 'Cloak': 'Cloak', + 'Robe': 'Robe' } - + translated_parts = [name_mapping.get(part, part) for part in slot_parts] - return ", ".join(translated_parts) - + return ', '.join(translated_parts) + # Handle special cases for high values (like Aetheria slots) - if ( - wielded_location >= 268435456 - ): # 2^28 and higher - likely Aetheria or special slots + if wielded_location >= 268435456: # 2^28 and higher - likely Aetheria or special slots if wielded_location == 268435456: return "Aetheria Blue" elif wielded_location == 536870912: - return "Aetheria Yellow" + return "Aetheria Yellow" elif wielded_location == 1073741824: return "Aetheria Red" else: return f"Special Slot ({wielded_location})" - + return "-" - def translate_workmanship(workmanship_value: int) -> str: """Translate workmanship value to descriptive text.""" if workmanship_value <= 0: @@ -942,12 +844,11 @@ def translate_workmanship(workmanship_value: int) -> str: else: return f"Quality ({workmanship_value})" - def format_damage_resistance(armor_level: int, damage_type: str) -> str: """Format damage resistance values with descriptive text.""" if armor_level <= 0: return "" - + # Rough categorization based on armor level if armor_level < 200: category = "Poor" @@ -961,655 +862,461 @@ def format_damage_resistance(armor_level: int, damage_type: str) -> str: category = "Excellent" else: category = "Superior" - + return f"{category} ({armor_level})" - def get_damage_range_and_type(item_data: Dict[str, Any]) -> Dict[str, Any]: """Calculate damage range and determine damage type for weapons.""" damage_info = {} - - int_values = item_data.get("IntValues", {}) - double_values = item_data.get("DoubleValues", {}) - + + int_values = item_data.get('IntValues', {}) + double_values = item_data.get('DoubleValues', {}) + # Max damage max_damage = None - if "218103842" in int_values: - max_damage = int_values["218103842"] + if '218103842' in int_values: + max_damage = int_values['218103842'] elif 218103842 in int_values: max_damage = int_values[218103842] - + # Variance for damage range calculation variance = None - if "167772171" in double_values: - variance = double_values["167772171"] + if '167772171' in double_values: + variance = double_values['167772171'] elif 167772171 in double_values: variance = double_values[167772171] - + # Damage bonus damage_bonus = None - if "167772174" in double_values: - damage_bonus = double_values["167772174"] + if '167772174' in double_values: + damage_bonus = double_values['167772174'] elif 167772174 in double_values: damage_bonus = double_values[167772174] - + if max_damage and variance: # Calculate min damage: max_damage * (2 - variance) / 2 min_damage = max_damage * (2 - variance) / 2 - damage_info["damage_range"] = f"{min_damage:.2f} - {max_damage}" - damage_info["min_damage"] = min_damage - damage_info["max_damage"] = max_damage + damage_info['damage_range'] = f"{min_damage:.2f} - {max_damage}" + damage_info['min_damage'] = min_damage + damage_info['max_damage'] = max_damage elif max_damage: - damage_info["damage_range"] = str(max_damage) - damage_info["max_damage"] = max_damage - + damage_info['damage_range'] = str(max_damage) + damage_info['max_damage'] = max_damage + # Determine damage type from item name or properties - item_name = item_data.get("Name", "").lower() - if "flaming" in item_name or "fire" in item_name: - damage_info["damage_type"] = "Fire" - elif "frost" in item_name or "ice" in item_name: - damage_info["damage_type"] = "Cold" - elif "lightning" in item_name or "electric" in item_name: - damage_info["damage_type"] = "Electrical" - elif "acid" in item_name: - damage_info["damage_type"] = "Acid" + item_name = item_data.get('Name', '').lower() + if 'flaming' in item_name or 'fire' in item_name: + damage_info['damage_type'] = 'Fire' + elif 'frost' in item_name or 'ice' in item_name: + damage_info['damage_type'] = 'Cold' + elif 'lightning' in item_name or 'electric' in item_name: + damage_info['damage_type'] = 'Electrical' + elif 'acid' in item_name: + damage_info['damage_type'] = 'Acid' else: # Check for elemental damage bonus elemental_bonus = None - if "204" in int_values: - elemental_bonus = int_values["204"] + if '204' in int_values: + elemental_bonus = int_values['204'] elif 204 in int_values: elemental_bonus = int_values[204] - + if elemental_bonus and elemental_bonus > 0: - damage_info["damage_type"] = "Elemental" + damage_info['damage_type'] = 'Elemental' else: - damage_info["damage_type"] = "Physical" - + damage_info['damage_type'] = 'Physical' + return damage_info - def get_weapon_speed(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get weapon speed information.""" speed_info = {} - - int_values = item_data.get("IntValues", {}) - + + int_values = item_data.get('IntValues', {}) + # Weapon speed (check multiple possible keys) speed_value = None - speed_keys = ["169", 169, "WeapSpeed_Decal"] + speed_keys = ['169', 169, 'WeapSpeed_Decal'] for key in speed_keys: if key in int_values: speed_value = int_values[key] break - + # Also check translated enum properties for speed translated_ints = translate_int_values(int_values) - if "WeaponSpeed" in translated_ints: - speed_value = translated_ints["WeaponSpeed"] - elif "WeapSpeed" in translated_ints: - speed_value = translated_ints["WeapSpeed"] - elif "WeapSpeed_Decal" in translated_ints: - speed_value = translated_ints["WeapSpeed_Decal"] - + if 'WeaponSpeed' in translated_ints: + speed_value = translated_ints['WeaponSpeed'] + elif 'WeapSpeed' in translated_ints: + speed_value = translated_ints['WeapSpeed'] + elif 'WeapSpeed_Decal' in translated_ints: + speed_value = translated_ints['WeapSpeed_Decal'] + if speed_value: - speed_info["speed_value"] = speed_value - + speed_info['speed_value'] = speed_value + # Convert to descriptive text if speed_value <= 20: - speed_info["speed_text"] = f"Very Fast ({speed_value})" + speed_info['speed_text'] = f"Very Fast ({speed_value})" elif speed_value <= 30: - speed_info["speed_text"] = f"Fast ({speed_value})" + speed_info['speed_text'] = f"Fast ({speed_value})" elif speed_value <= 40: - speed_info["speed_text"] = f"Average ({speed_value})" + speed_info['speed_text'] = f"Average ({speed_value})" elif speed_value <= 50: - speed_info["speed_text"] = f"Slow ({speed_value})" + speed_info['speed_text'] = f"Slow ({speed_value})" else: - speed_info["speed_text"] = f"Very Slow ({speed_value})" - + speed_info['speed_text'] = f"Very Slow ({speed_value})" + return speed_info - def get_mana_and_spellcraft(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get mana and spellcraft information for items.""" mana_info = {} - - int_values = item_data.get("IntValues", {}) - + + int_values = item_data.get('IntValues', {}) + # Get translated enum properties first translated_ints = translate_int_values(int_values) - + # Current mana - check translated properties first current_mana = None - if "ItemCurMana" in translated_ints: - current_mana = translated_ints["ItemCurMana"] - elif "73" in int_values: - current_mana = int_values["73"] + if 'ItemCurMana' in translated_ints: + current_mana = translated_ints['ItemCurMana'] + elif '73' in int_values: + current_mana = int_values['73'] elif 73 in int_values: current_mana = int_values[73] - + # Max mana - check translated properties first max_mana = None - if "ItemMaxMana" in translated_ints: - max_mana = translated_ints["ItemMaxMana"] - elif "72" in int_values: - max_mana = int_values["72"] + if 'ItemMaxMana' in translated_ints: + max_mana = translated_ints['ItemMaxMana'] + elif '72' in int_values: + max_mana = int_values['72'] elif 72 in int_values: max_mana = int_values[72] - - # Spellcraft - check translated properties first + + # Spellcraft - check translated properties first spellcraft = None - if "ItemSpellcraft" in translated_ints: - spellcraft = translated_ints["ItemSpellcraft"] - elif "106" in int_values: - spellcraft = int_values["106"] + if 'ItemSpellcraft' in translated_ints: + spellcraft = translated_ints['ItemSpellcraft'] + elif '106' in int_values: + spellcraft = int_values['106'] elif 106 in int_values: spellcraft = int_values[106] - + if current_mana is not None and max_mana is not None: - mana_info["mana_display"] = f"{current_mana} / {max_mana}" - mana_info["current_mana"] = current_mana - mana_info["max_mana"] = max_mana - + mana_info['mana_display'] = f"{current_mana} / {max_mana}" + mana_info['current_mana'] = current_mana + mana_info['max_mana'] = max_mana + if spellcraft: - mana_info["spellcraft"] = spellcraft - + mana_info['spellcraft'] = spellcraft + return mana_info - -def _format_mana_time_remaining(total_seconds: Optional[int]) -> Optional[str]: - """Format remaining mana time in a compact display form.""" - if total_seconds is None or total_seconds < 0: - return None - - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h{minutes:02d}m" - - -def get_mana_tracker_info(item_data: Dict[str, Any]) -> Dict[str, Any]: - """Build derived mana-tracker fields for equipped inventory display.""" - mana_info = get_mana_and_spellcraft(item_data) - int_values = item_data.get("IntValues", {}) - double_values = item_data.get("DoubleValues", {}) - - current_mana = mana_info.get("current_mana") - max_mana = mana_info.get("max_mana") - has_id_data = bool(item_data.get("HasIdData", False)) - spell_ids = item_data.get("Spells", []) or [] - active_spells = item_data.get("ActiveSpells", []) or [] - active_item_enchantments = item_data.get("ActiveItemEnchantments", []) or [] - has_active_spell_context = bool(active_spells) or bool(active_item_enchantments) - - mana_rate_of_change = None - if "5" in double_values: - mana_rate_of_change = double_values["5"] - elif 5 in double_values: - mana_rate_of_change = double_values[5] - - def is_spell_active(spell_data: Dict[str, Any]) -> bool: - if not spell_data: - return False - - spell_id = spell_data.get("id") - if spell_id in active_spells: - return True - - spell_family = spell_data.get("family") - spell_difficulty = spell_data.get("difficulty") - if spell_family in (None, "", 0): - return False - - for active_spell in active_item_enchantments: - if not active_spell: - continue - if active_spell.get("family") != spell_family: - continue - - active_difficulty = active_spell.get("difficulty") - if active_difficulty in (None, "") or spell_difficulty in (None, ""): - return True - - try: - if int(active_difficulty) >= int(spell_difficulty): - return True - except (TypeError, ValueError): - return True - - return False - - translated_spells = [translate_spell(spell_id) for spell_id in spell_ids] - actionable_spells = [] - for spell in translated_spells: - if not spell: - continue - if spell.get("id") == int_values.get("94") or spell.get("id") == int_values.get( - 94 - ): - continue - spell_name = (spell.get("name") or "").lower() - if spell_name.startswith("unknown_spell_"): - continue - if spell_name.startswith(("cantrip portal send", "cantrip portal recall")): - continue - if spell_name.startswith(("incantation of ", "aura of incantation ")): - actionable_spells.append(spell) - continue - if spell_name.startswith( - ( - "feeble ", - "minor ", - "lesser ", - "moderate ", - "inner ", - "major ", - "epic ", - "legendary ", - "prodigal ", - ) - ): - actionable_spells.append(spell) - continue - duration = spell.get("duration") - try: - if duration is not None and int(duration) <= 0: - actionable_spells.append(spell) - except (TypeError, ValueError): - pass - - has_inactive_spell = any(not is_spell_active(spell) for spell in actionable_spells) - - if not has_id_data: - mana_state = "unknown" - elif not spell_ids or max_mana is None or max_mana <= 0: - mana_state = "not_activatable" - elif current_mana is None: - mana_state = "unknown" - elif current_mana <= 0: - mana_state = "not_active" - elif mana_rate_of_change is not None: - mana_state = "active" if mana_rate_of_change < 0 else "not_active" - elif has_active_spell_context: - mana_state = "not_active" if has_inactive_spell else "active" - elif actionable_spells: - mana_state = "active" - else: - mana_state = "unknown" - - seconds_per_burn = None - if mana_rate_of_change is not None and mana_rate_of_change < 0: - try: - seconds_per_burn = int(math.ceil(-0.2 / float(mana_rate_of_change))) * 5 - if seconds_per_burn <= 0: - seconds_per_burn = None - except (ValueError, ZeroDivisionError, TypeError): - seconds_per_burn = None - - mana_time_remaining_seconds = None - if mana_state == "active" and current_mana is not None and seconds_per_burn: - mana_time_remaining_seconds = max(int(current_mana) * int(seconds_per_burn), 0) - - equipped_location = int_values.get("10", int_values.get(10, 0)) - - return { - "mana_state": mana_state, - "mana_rate_of_change": mana_rate_of_change, - "seconds_per_burn": seconds_per_burn, - "mana_time_remaining_seconds": mana_time_remaining_seconds, - "mana_time_remaining_display": _format_mana_time_remaining( - mana_time_remaining_seconds - ), - "mana_snapshot_utc": datetime.utcnow().isoformat(), - "is_mana_tracked": bool(equipped_location) - and ( - (max_mana is not None and max_mana > 0) - or (current_mana is not None and current_mana > 0) - or bool(spell_ids) - or bool(mana_info.get("spellcraft")) - ), - } - - def get_comprehensive_translations(item_data: Dict[str, Any]) -> Dict[str, Any]: """Get comprehensive translations for all aspects of an item.""" translations = {} - + # Translate IntValues - int_values = item_data.get("IntValues", {}) + int_values = item_data.get('IntValues', {}) if int_values: - translations["int_properties"] = translate_int_values(int_values) - + translations['int_properties'] = translate_int_values(int_values) + # Translate material if present (check multiple locations) - material_id = item_data.get("MaterialType") + material_id = item_data.get('MaterialType') if material_id is None: # Check IntValues for material (key 131) - int_values = item_data.get("IntValues", {}) - if isinstance(int_values, dict) and "131" in int_values: - material_id = int_values["131"] + int_values = item_data.get('IntValues', {}) + if isinstance(int_values, dict) and '131' in int_values: + material_id = int_values['131'] elif isinstance(int_values, dict) and 131 in int_values: material_id = int_values[131] - + if material_id is not None and material_id != 0: - translations["material_name"] = translate_material_type(material_id) - translations["material_id"] = material_id - + translations['material_name'] = translate_material_type(material_id) + translations['material_id'] = material_id + # Translate item type if present (check ItemType field or IntValues[1]) - item_type_id = item_data.get("ItemType") + item_type_id = item_data.get('ItemType') if item_type_id is None: # Check IntValues for ItemType (key 1) - int_values = item_data.get("IntValues", {}) + int_values = item_data.get('IntValues', {}) if isinstance(int_values, dict): - item_type_id = int_values.get("1", int_values.get(1)) - + item_type_id = int_values.get('1', int_values.get(1)) + if item_type_id is not None: - translations["item_type_name"] = translate_item_type(item_type_id) + translations['item_type_name'] = translate_item_type(item_type_id) else: # Fallback: derive ItemType from ObjectClass - object_class = item_data.get("ObjectClass") + object_class = item_data.get('ObjectClass') if object_class is not None: - translations["item_type_name"] = derive_item_type_from_object_class( - object_class, item_data - ) - + translations['item_type_name'] = derive_item_type_from_object_class(object_class, item_data) + # Translate object class using WeenieType enum - object_class = item_data.get("ObjectClass") + object_class = item_data.get('ObjectClass') if object_class is not None: - translations["object_class_name"] = translate_object_class( - object_class, item_data - ) - + translations['object_class_name'] = translate_object_class(object_class, item_data) + return translations - def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: """Extract and categorize item properties from raw JSON.""" - + # Get raw values for comprehensive extraction - int_values = item_data.get("IntValues", {}) - double_values = item_data.get("DoubleValues", {}) - + int_values = item_data.get('IntValues', {}) + double_values = item_data.get('DoubleValues', {}) + # Start with processed fields (if available) properties = { - "basic": { - "name": item_data.get("Name", ""), - "icon": item_data.get("Icon", 0), - "object_class": item_data.get("ObjectClass", 0), - "value": item_data.get("Value", 0), - "burden": item_data.get("Burden", 0), - "has_id_data": item_data.get("HasIdData", False), + 'basic': { + 'name': item_data.get('Name', ''), + 'icon': item_data.get('Icon', 0), + 'object_class': item_data.get('ObjectClass', 0), + 'value': item_data.get('Value', 0), + 'burden': item_data.get('Burden', 0), + 'has_id_data': item_data.get('HasIdData', False), + # Equipment status - handle string keys properly - "current_wielded_location": int( - int_values.get("10", int_values.get(10, "0")) - ), + 'current_wielded_location': int(int_values.get('10', int_values.get(10, '0'))), + # Item state - "bonded": int_values.get("33", int_values.get(33, 0)), - "attuned": int_values.get("114", int_values.get(114, 0)), - "unique": bool(int_values.get("279", int_values.get(279, 0))), + 'bonded': int_values.get('33', int_values.get(33, 0)), + 'attuned': int_values.get('114', int_values.get(114, 0)), + 'unique': bool(int_values.get('279', int_values.get(279, 0))), + # Stack/Container properties - "stack_size": int_values.get("12", int_values.get(12, 1)), - "max_stack_size": int_values.get("11", int_values.get(11, 1)), - "items_capacity": int_values.get("6", int_values.get(6, -1)), - "containers_capacity": int_values.get("7", int_values.get(7, -1)), + 'stack_size': int_values.get('12', int_values.get(12, 1)), + 'max_stack_size': int_values.get('11', int_values.get(11, 1)), + 'items_capacity': int_values.get('6', int_values.get(6, -1)), + 'containers_capacity': int_values.get('7', int_values.get(7, -1)), + # Durability - "structure": int_values.get("92", int_values.get(92, -1)), - "max_structure": int_values.get("91", int_values.get(91, -1)), + 'structure': int_values.get('92', int_values.get(92, -1)), + 'max_structure': int_values.get('91', int_values.get(91, -1)), + # Special item flags - "rare_id": int_values.get("17", int_values.get(17, -1)), - "lifespan": int_values.get("267", int_values.get(267, -1)), - "remaining_lifespan": int_values.get("268", int_values.get(268, -1)), + 'rare_id': int_values.get('17', int_values.get(17, -1)), + 'lifespan': int_values.get('267', int_values.get(267, -1)), + 'remaining_lifespan': int_values.get('268', int_values.get(268, -1)), }, - "combat": { - "max_damage": item_data.get("MaxDamage", -1), - "armor_level": item_data.get("ArmorLevel", -1), - "damage_bonus": item_data.get("DamageBonus", -1.0), - "attack_bonus": item_data.get("AttackBonus", -1.0), + 'combat': { + 'max_damage': item_data.get('MaxDamage', -1), + 'armor_level': item_data.get('ArmorLevel', -1), + 'damage_bonus': item_data.get('DamageBonus', -1.0), + 'attack_bonus': item_data.get('AttackBonus', -1.0), + # Defense bonuses from raw values - "melee_defense_bonus": double_values.get("29", double_values.get(29, -1.0)), - "magic_defense_bonus": double_values.get( - "150", double_values.get(150, -1.0) - ), - "missile_defense_bonus": double_values.get( - "149", double_values.get(149, -1.0) - ), - "elemental_damage_vs_monsters": double_values.get( - "152", double_values.get(152, -1.0) - ), - "mana_conversion_bonus": double_values.get( - "144", double_values.get(144, -1.0) - ), + 'melee_defense_bonus': double_values.get('29', double_values.get(29, -1.0)), + 'magic_defense_bonus': double_values.get('150', double_values.get(150, -1.0)), + 'missile_defense_bonus': double_values.get('149', double_values.get(149, -1.0)), + 'elemental_damage_vs_monsters': double_values.get('152', double_values.get(152, -1.0)), + 'mana_conversion_bonus': double_values.get('144', double_values.get(144, -1.0)), + # Advanced damage properties - "cleaving": int_values.get("292", int_values.get(292, -1)), - "elemental_damage_bonus": int_values.get("204", int_values.get(204, -1)), - "crit_damage_rating": int_values.get("314", int_values.get(314, -1)), - "damage_over_time": int_values.get("318", int_values.get(318, -1)), + 'cleaving': int_values.get('292', int_values.get(292, -1)), + 'elemental_damage_bonus': int_values.get('204', int_values.get(204, -1)), + 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), + 'damage_over_time': int_values.get('318', int_values.get(318, -1)), + # Resistances - "resist_magic": int_values.get("36", int_values.get(36, -1)), - "crit_resist_rating": int_values.get("315", int_values.get(315, -1)), - "crit_damage_resist_rating": int_values.get("316", int_values.get(316, -1)), - "dot_resist_rating": int_values.get("350", int_values.get(350, -1)), - "life_resist_rating": int_values.get("351", int_values.get(351, -1)), - "nether_resist_rating": int_values.get("331", int_values.get(331, -1)), + 'resist_magic': int_values.get('36', int_values.get(36, -1)), + 'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), + 'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), + 'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), + 'life_resist_rating': int_values.get('351', int_values.get(351, -1)), + 'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), + # Healing/Recovery - "heal_over_time": int_values.get("312", int_values.get(312, -1)), - "healing_resist_rating": int_values.get("317", int_values.get(317, -1)), + 'heal_over_time': int_values.get('312', int_values.get(312, -1)), + 'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), + # PvP properties - "pk_damage_rating": int_values.get("381", int_values.get(381, -1)), - "pk_damage_resist_rating": int_values.get("382", int_values.get(382, -1)), - "gear_pk_damage_rating": int_values.get("383", int_values.get(383, -1)), - "gear_pk_damage_resist_rating": int_values.get( - "384", int_values.get(384, -1) - ), + 'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), + 'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), + 'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), + 'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), }, - "requirements": { - "wield_level": item_data.get("WieldLevel", -1), - "skill_level": item_data.get("SkillLevel", -1), - "lore_requirement": item_data.get("LoreRequirement", -1), - "equip_skill": item_data.get("EquipSkill"), + 'requirements': { + 'wield_level': item_data.get('WieldLevel', -1), + 'skill_level': item_data.get('SkillLevel', -1), + 'lore_requirement': item_data.get('LoreRequirement', -1), + 'equip_skill': item_data.get('EquipSkill'), }, - "enhancements": { - "material": None, # Will be set below with proper logic - "imbue": item_data.get("Imbue"), - "tinks": item_data.get("Tinks", -1), - "workmanship": item_data.get("Workmanship", -1.0), - "item_set": None, # Will be set below with translation + 'enhancements': { + 'material': None, # Will be set below with proper logic + 'imbue': item_data.get('Imbue'), + 'tinks': item_data.get('Tinks', -1), + 'workmanship': item_data.get('Workmanship', -1.0), + 'item_set': None, # Will be set below with translation + # Advanced tinkering - "num_times_tinkered": int_values.get("171", int_values.get(171, -1)), - "free_tinkers_bitfield": int_values.get("264", int_values.get(264, -1)), - "num_items_in_material": int_values.get("170", int_values.get(170, -1)), + 'num_times_tinkered': int_values.get('171', int_values.get(171, -1)), + 'free_tinkers_bitfield': int_values.get('264', int_values.get(264, -1)), + 'num_items_in_material': int_values.get('170', int_values.get(170, -1)), + # Additional imbue effects - "imbue_attempts": int_values.get("205", int_values.get(205, -1)), - "imbue_successes": int_values.get("206", int_values.get(206, -1)), - "imbued_effect2": int_values.get("303", int_values.get(303, -1)), - "imbued_effect3": int_values.get("304", int_values.get(304, -1)), - "imbued_effect4": int_values.get("305", int_values.get(305, -1)), - "imbued_effect5": int_values.get("306", int_values.get(306, -1)), - "imbue_stacking_bits": int_values.get("311", int_values.get(311, -1)), + 'imbue_attempts': int_values.get('205', int_values.get(205, -1)), + 'imbue_successes': int_values.get('206', int_values.get(206, -1)), + 'imbued_effect2': int_values.get('303', int_values.get(303, -1)), + 'imbued_effect3': int_values.get('304', int_values.get(304, -1)), + 'imbued_effect4': int_values.get('305', int_values.get(305, -1)), + 'imbued_effect5': int_values.get('306', int_values.get(306, -1)), + 'imbue_stacking_bits': int_values.get('311', int_values.get(311, -1)), + # Set information - "equipment_set_extra": int_values.get("321", int_values.get(321, -1)), + 'equipment_set_extra': int_values.get('321', int_values.get(321, -1)), + # Special properties - "aetheria_bitfield": int_values.get("322", int_values.get(322, -1)), - "heritage_specific_armor": int_values.get("324", int_values.get(324, -1)), + 'aetheria_bitfield': int_values.get('322', int_values.get(322, -1)), + 'heritage_specific_armor': int_values.get('324', int_values.get(324, -1)), + # Cooldowns - "shared_cooldown": int_values.get("280", int_values.get(280, -1)), + 'shared_cooldown': int_values.get('280', int_values.get(280, -1)), }, - "ratings": { - "damage_rating": int_values.get( - "307", int_values.get(307, -1) - ), # DamageRating - "crit_rating": int_values.get("313", int_values.get(313, -1)), # CritRating - "crit_damage_rating": int_values.get( - "314", int_values.get(314, -1) - ), # CritDamageRating - "heal_boost_rating": int_values.get( - "323", int_values.get(323, -1) - ), # HealingBoostRating + 'ratings': { + 'damage_rating': int_values.get('307', int_values.get(307, -1)), # DamageRating + 'crit_rating': int_values.get('313', int_values.get(313, -1)), # CritRating + 'crit_damage_rating': int_values.get('314', int_values.get(314, -1)), # CritDamageRating + 'heal_boost_rating': int_values.get('323', int_values.get(323, -1)), # HealingBoostRating + # Missing critical ratings - "damage_resist_rating": int_values.get( - "308", int_values.get(308, -1) - ), # DamageResistRating - "crit_resist_rating": int_values.get( - "315", int_values.get(315, -1) - ), # CritResistRating - "crit_damage_resist_rating": int_values.get( - "316", int_values.get(316, -1) - ), # CritDamageResistRating - "healing_resist_rating": int_values.get( - "317", int_values.get(317, -1) - ), # HealingResistRating - "nether_resist_rating": int_values.get( - "331", int_values.get(331, -1) - ), # NetherResistRating - "vitality_rating": int_values.get( - "341", int_values.get(341, -1) - ), # VitalityRating - "healing_rating": int_values.get( - "342", int_values.get(342, -1) - ), # LumAugHealingRating - "dot_resist_rating": int_values.get( - "350", int_values.get(350, -1) - ), # DotResistRating - "life_resist_rating": int_values.get( - "351", int_values.get(351, -1) - ), # LifeResistRating + 'damage_resist_rating': int_values.get('308', int_values.get(308, -1)), # DamageResistRating + 'crit_resist_rating': int_values.get('315', int_values.get(315, -1)), # CritResistRating + 'crit_damage_resist_rating': int_values.get('316', int_values.get(316, -1)), # CritDamageResistRating + 'healing_resist_rating': int_values.get('317', int_values.get(317, -1)), # HealingResistRating + 'nether_resist_rating': int_values.get('331', int_values.get(331, -1)), # NetherResistRating + 'vitality_rating': int_values.get('341', int_values.get(341, -1)), # VitalityRating + 'healing_rating': int_values.get('342', int_values.get(342, -1)), # LumAugHealingRating + 'dot_resist_rating': int_values.get('350', int_values.get(350, -1)), # DotResistRating + 'life_resist_rating': int_values.get('351', int_values.get(351, -1)), # LifeResistRating + # Specialized ratings - "sneak_attack_rating": int_values.get( - "356", int_values.get(356, -1) - ), # SneakAttackRating - "recklessness_rating": int_values.get( - "357", int_values.get(357, -1) - ), # RecklessnessRating - "deception_rating": int_values.get( - "358", int_values.get(358, -1) - ), # DeceptionRating + 'sneak_attack_rating': int_values.get('356', int_values.get(356, -1)), # SneakAttackRating + 'recklessness_rating': int_values.get('357', int_values.get(357, -1)), # RecklessnessRating + 'deception_rating': int_values.get('358', int_values.get(358, -1)), # DeceptionRating + # PvP ratings - "pk_damage_rating": int_values.get( - "381", int_values.get(381, -1) - ), # PKDamageRating - "pk_damage_resist_rating": int_values.get( - "382", int_values.get(382, -1) - ), # PKDamageResistRating - "gear_pk_damage_rating": int_values.get( - "383", int_values.get(383, -1) - ), # GearPKDamageRating - "gear_pk_damage_resist_rating": int_values.get( - "384", int_values.get(384, -1) - ), # GearPKDamageResistRating + 'pk_damage_rating': int_values.get('381', int_values.get(381, -1)), # PKDamageRating + 'pk_damage_resist_rating': int_values.get('382', int_values.get(382, -1)), # PKDamageResistRating + 'gear_pk_damage_rating': int_values.get('383', int_values.get(383, -1)), # GearPKDamageRating + 'gear_pk_damage_resist_rating': int_values.get('384', int_values.get(384, -1)), # GearPKDamageResistRating + # Additional ratings - "weakness_rating": int_values.get("329", int_values.get(329, -1)), - "nether_over_time": int_values.get("330", int_values.get(330, -1)), + 'weakness_rating': int_values.get('329', int_values.get(329, -1)), + 'nether_over_time': int_values.get('330', int_values.get(330, -1)), + # Gear totals - "gear_damage": int_values.get("370", int_values.get(370, -1)), - "gear_damage_resist": int_values.get("371", int_values.get(371, -1)), - "gear_crit": int_values.get("372", int_values.get(372, -1)), - "gear_crit_resist": int_values.get("373", int_values.get(373, -1)), - "gear_crit_damage": int_values.get("374", int_values.get(374, -1)), - "gear_crit_damage_resist": int_values.get("375", int_values.get(375, -1)), - "gear_healing_boost": int_values.get("376", int_values.get(376, -1)), - "gear_max_health": int_values.get("379", int_values.get(379, -1)), - "gear_nether_resist": int_values.get("377", int_values.get(377, -1)), - "gear_life_resist": int_values.get("378", int_values.get(378, -1)), - "gear_overpower": int_values.get("388", int_values.get(388, -1)), - "gear_overpower_resist": int_values.get("389", int_values.get(389, -1)), - }, + 'gear_damage': int_values.get('370', int_values.get(370, -1)), + 'gear_damage_resist': int_values.get('371', int_values.get(371, -1)), + 'gear_crit': int_values.get('372', int_values.get(372, -1)), + 'gear_crit_resist': int_values.get('373', int_values.get(373, -1)), + 'gear_crit_damage': int_values.get('374', int_values.get(374, -1)), + 'gear_crit_damage_resist': int_values.get('375', int_values.get(375, -1)), + 'gear_healing_boost': int_values.get('376', int_values.get(376, -1)), + 'gear_max_health': int_values.get('379', int_values.get(379, -1)), + 'gear_nether_resist': int_values.get('377', int_values.get(377, -1)), + 'gear_life_resist': int_values.get('378', int_values.get(378, -1)), + 'gear_overpower': int_values.get('388', int_values.get(388, -1)), + 'gear_overpower_resist': int_values.get('389', int_values.get(389, -1)), + } } - + # Handle material field properly - check if already translated or needs translation - material_field = item_data.get("Material") + material_field = item_data.get('Material') if material_field and isinstance(material_field, str): # Material is already a translated string (like "Gold", "Iron", "Brass") - properties["enhancements"]["material"] = material_field + properties['enhancements']['material'] = material_field else: # Material needs translation from IntValues[131] - material_id = int_values.get("131", int_values.get(131)) + material_id = int_values.get('131', int_values.get(131)) if material_id: material_name = translate_material_type(material_id) # Only store if translation succeeded (not "Unknown_Material_*") - if not material_name.startswith("Unknown_Material_"): - properties["enhancements"]["material"] = material_name - + if not material_name.startswith('Unknown_Material_'): + properties['enhancements']['material'] = material_name + # Translate item_set ID to name for database storage - item_set_id = int_values.get("265", int_values.get(265)) + item_set_id = int_values.get('265', int_values.get(265)) if item_set_id: - dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) - attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) set_name = attribute_set_info.get(str(item_set_id)) if set_name: - properties["enhancements"]["item_set"] = set_name + properties['enhancements']['item_set'] = set_name else: # Fallback to just store the ID as string - properties["enhancements"]["item_set"] = str(item_set_id) - + properties['enhancements']['item_set'] = str(item_set_id) + # Get comprehensive translations translations = get_comprehensive_translations(item_data) if translations: - properties["translations"] = translations - + properties['translations'] = translations + # Translate raw enum values if available - int_values = item_data.get("IntValues", {}) + int_values = item_data.get('IntValues', {}) if int_values: translated_ints = translate_int_values(int_values) - properties["translated_ints"] = translated_ints - + properties['translated_ints'] = translated_ints + # Extract spell information - spells = item_data.get("Spells", []) - active_spells = item_data.get("ActiveSpells", []) - + spells = item_data.get('Spells', []) + active_spells = item_data.get('ActiveSpells', []) + # Translate spell IDs to spell data translated_spells = [] for spell_id in spells: translated_spells.append(translate_spell(spell_id)) - + translated_active_spells = [] for spell_id in active_spells: translated_active_spells.append(translate_spell(spell_id)) - + # Get spell-related properties from IntValues - int_values = item_data.get("IntValues", {}) + int_values = item_data.get('IntValues', {}) spell_info = { - "spell_ids": spells, - "active_spell_ids": active_spells, - "spell_count": len(spells), - "active_spell_count": len(active_spells), - "spells": translated_spells, - "active_spells": translated_active_spells, + 'spell_ids': spells, + 'active_spell_ids': active_spells, + 'spell_count': len(spells), + 'active_spell_count': len(active_spells), + 'spells': translated_spells, + 'active_spells': translated_active_spells } - + # Extract spell-related IntValues for key_str, value in int_values.items(): key_int = int(key_str) if key_str.isdigit() else None if key_int: # Check for spell-related properties if key_int == 94: # TargetType/SpellDID - spell_info["spell_target_type"] = value + spell_info['spell_target_type'] = value elif key_int == 106: # ItemSpellcraft - spell_info["item_spellcraft"] = value + spell_info['item_spellcraft'] = value elif key_int in [218103816, 218103838, 218103848]: # Spell decals - spell_info[f"spell_decal_{key_int}"] = value - - properties["spells"] = spell_info - + spell_info[f'spell_decal_{key_int}'] = value + + properties['spells'] = spell_info + # Add weapon-specific information damage_info = get_damage_range_and_type(item_data) if damage_info: - properties["weapon_damage"] = damage_info - + properties['weapon_damage'] = damage_info + speed_info = get_weapon_speed(item_data) if speed_info: - properties["weapon_speed"] = speed_info - + properties['weapon_speed'] = speed_info + mana_info = get_mana_and_spellcraft(item_data) if mana_info: - properties["mana_info"] = mana_info - + properties['mana_info'] = mana_info + return properties - # API endpoints -@app.post( - "/process-inventory", - response_model=ProcessingStats, - summary="Process Raw Inventory Data", - description=""" +@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 @@ -1635,318 +1342,225 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]: Returns detailed error information for items that fail to process, including SQL type errors, missing fields, and validation failures. """, - tags=["Data Processing"], -) + tags=["Data Processing"]) async def process_inventory(inventory: InventoryItem): """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 # Get item IDs to delete item_ids_query = "SELECT id FROM items WHERE character_name = :character_name" - item_ids = await database.fetch_all( - item_ids_query, {"character_name": inventory.character_name} - ) - + item_ids = await database.fetch_all(item_ids_query, {"character_name": inventory.character_name}) + if item_ids: - db_ids = [row["id"] for row in item_ids] + db_ids = [row['id'] for row in item_ids] # Delete from all related tables first - for table in ( - "item_raw_data", - "item_combat_stats", - "item_requirements", - "item_enhancements", - "item_ratings", - "item_spells", - ): + for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', + 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} + sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + {"ids": db_ids} ) - + # Finally delete from main items table await database.execute( "DELETE FROM items WHERE character_name = :character_name", - {"character_name": inventory.character_name}, + {"character_name": inventory.character_name} ) - + # Then insert the new complete inventory for item_data in inventory.items: try: # Extract properties properties = extract_item_properties(item_data) - + # Create core item record - item_id = item_data.get("Id") + item_id = item_data.get('Id') if item_id is None: - logger.warning( - f"Skipping item without ID: {item_data.get('Name', 'Unknown')}" - ) + logger.warning(f"Skipping item without ID: {item_data.get('Name', 'Unknown')}") error_count += 1 continue - + # Insert or update core item (handle timezone-aware timestamps) timestamp = inventory.timestamp if timestamp.tzinfo is not None: timestamp = timestamp.replace(tzinfo=None) - + # Simple INSERT since we cleared the table first - basic = properties["basic"] - + basic = properties['basic'] + # Debug logging for problematic items if item_id in [-2133380247, -2144880287, -2136150336]: logger.info(f"Debug item {item_id}: basic={basic}") - logger.info( - f"Debug item {item_id}: name='{basic['name']}' type={type(basic['name'])}" - ) - logger.info( - f"Debug item {item_id}: current_wielded_location={basic['current_wielded_location']} type={type(basic['current_wielded_location'])}" - ) - logger.info( - f"Debug item {item_id}: enhancements={properties['enhancements']}" - ) + logger.info(f"Debug item {item_id}: name='{basic['name']}' type={type(basic['name'])}") + logger.info(f"Debug item {item_id}: current_wielded_location={basic['current_wielded_location']} type={type(basic['current_wielded_location'])}") + logger.info(f"Debug item {item_id}: enhancements={properties['enhancements']}") + + item_stmt = sa.insert(Item).values( + character_name=inventory.character_name, + item_id=item_id, + timestamp=timestamp, + name=basic['name'], + icon=basic['icon'], + object_class=basic['object_class'], + value=basic['value'], + burden=basic['burden'], + has_id_data=basic['has_id_data'], + last_id_time=item_data.get('LastIdTime', 0), - item_stmt = ( - sa.insert(Item) - .values( - character_name=inventory.character_name, - item_id=item_id, - timestamp=timestamp, - name=basic["name"], - icon=basic["icon"], - object_class=basic["object_class"], - value=basic["value"], - burden=basic["burden"], - has_id_data=basic["has_id_data"], - last_id_time=item_data.get("LastIdTime", 0), - # Equipment status - current_wielded_location=basic["current_wielded_location"], - # Container/position tracking - container_id=item_data.get("ContainerId", 0), - slot=int( - item_data.get("IntValues", {}).get( - "231735296", - item_data.get("IntValues", {}).get(231735296, -1), - ) - ), # Decal Slot_Decal key - # Item state - bonded=basic["bonded"], - attuned=basic["attuned"], - unique=basic["unique"], - # Stack/Container properties - stack_size=basic["stack_size"], - max_stack_size=basic["max_stack_size"], - items_capacity=basic["items_capacity"] - if basic["items_capacity"] != -1 - else None, - containers_capacity=basic["containers_capacity"] - if basic["containers_capacity"] != -1 - else None, - # Durability - structure=basic["structure"] - if basic["structure"] != -1 - else None, - max_structure=basic["max_structure"] - if basic["max_structure"] != -1 - else None, - # Special item flags - rare_id=basic["rare_id"] if basic["rare_id"] != -1 else None, - lifespan=basic["lifespan"] if basic["lifespan"] != -1 else None, - remaining_lifespan=basic["remaining_lifespan"] - if basic["remaining_lifespan"] != -1 - else None, - ) - .returning(Item.id) - ) + # Equipment status + current_wielded_location=basic['current_wielded_location'], + # Container/position tracking + container_id=item_data.get('ContainerId', 0), + slot=int(item_data.get('IntValues', {}).get('231735296', item_data.get('IntValues', {}).get(231735296, -1))), # Decal Slot_Decal key + + # Item state + bonded=basic['bonded'], + attuned=basic['attuned'], + unique=basic['unique'], + + # Stack/Container properties + stack_size=basic['stack_size'], + max_stack_size=basic['max_stack_size'], + items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, + containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, + + # Durability + structure=basic['structure'] if basic['structure'] != -1 else None, + max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, + + # Special item flags + rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, + lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, + remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, + ).returning(Item.id) + result = await database.fetch_one(item_stmt) - db_item_id = result["id"] - + db_item_id = result['id'] + # Store combat stats if applicable - combat = properties["combat"] + combat = properties['combat'] if any(v != -1 and v != -1.0 for v in combat.values()): - combat_stmt = ( - sa.dialects.postgresql.insert(ItemCombatStats) - .values( - item_id=db_item_id, - **{ - k: v if v != -1 and v != -1.0 else None - for k, v in combat.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v != -1 and v != -1.0 else None - for k, v in combat.items() - } - ), - ) + combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( + item_id=db_item_id, + **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) ) await database.execute(combat_stmt) - + # Store requirements if applicable - requirements = properties["requirements"] - if any(v not in [-1, None, ""] for v in requirements.values()): - req_stmt = ( - sa.dialects.postgresql.insert(ItemRequirements) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, None, ""] else None - for k, v in requirements.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, None, ""] else None - for k, v in requirements.items() - } - ), - ) + requirements = properties['requirements'] + if any(v not in [-1, None, ''] for v in requirements.values()): + req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( + item_id=db_item_id, + **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) ) await database.execute(req_stmt) - + # Store enhancements - always create record to capture item_set data - enhancements = properties["enhancements"] - enh_stmt = ( - sa.dialects.postgresql.insert(ItemEnhancements) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, -1.0, None, ""] else None - for k, v in enhancements.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, -1.0, None, ""] else None - for k, v in enhancements.items() - } - ), - ) + enhancements = properties['enhancements'] + enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) ) await database.execute(enh_stmt) - + # Store ratings if applicable - ratings = properties["ratings"] + ratings = properties['ratings'] if any(v not in [-1, -1.0, None] for v in ratings.values()): - rat_stmt = ( - sa.dialects.postgresql.insert(ItemRatings) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, -1.0, None] else None - for k, v in ratings.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, -1.0, None] else None - for k, v in ratings.items() - } - ), - ) + rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) ) await database.execute(rat_stmt) - + # Store spell data if applicable - spells = item_data.get("Spells", []) - active_spells = item_data.get("ActiveSpells", []) + spells = item_data.get('Spells', []) + active_spells = item_data.get('ActiveSpells', []) all_spells = set(spells + active_spells) - + if all_spells: # First delete existing spells for this item await database.execute( "DELETE FROM item_spells WHERE item_id = :item_id", - {"item_id": db_item_id}, + {"item_id": db_item_id} ) - + # Insert all spells for this item for spell_id in all_spells: is_active = spell_id in active_spells - spell_stmt = ( - sa.dialects.postgresql.insert(ItemSpells) - .values( - item_id=db_item_id, - spell_id=spell_id, - is_active=is_active, - ) - .on_conflict_do_nothing() - ) + spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( + item_id=db_item_id, + spell_id=spell_id, + is_active=is_active + ).on_conflict_do_nothing() await database.execute(spell_stmt) - + # Store raw data for completeness - raw_stmt = ( - sa.dialects.postgresql.insert(ItemRawData) - .values( - item_id=db_item_id, - int_values=item_data.get("IntValues", {}), - double_values=item_data.get("DoubleValues", {}), - string_values=item_data.get("StringValues", {}), - bool_values=item_data.get("BoolValues", {}), - original_json=item_data, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - int_values=item_data.get("IntValues", {}), - double_values=item_data.get("DoubleValues", {}), - string_values=item_data.get("StringValues", {}), - bool_values=item_data.get("BoolValues", {}), - original_json=item_data, - ), + raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( + item_id=db_item_id, + int_values=item_data.get('IntValues', {}), + double_values=item_data.get('DoubleValues', {}), + string_values=item_data.get('StringValues', {}), + bool_values=item_data.get('BoolValues', {}), + original_json=item_data + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict( + int_values=item_data.get('IntValues', {}), + double_values=item_data.get('DoubleValues', {}), + string_values=item_data.get('StringValues', {}), + bool_values=item_data.get('BoolValues', {}), + original_json=item_data ) ) await database.execute(raw_stmt) - + processed_count += 1 - + except Exception as e: - error_msg = ( - 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" - ) - + + logger.info(f"Inventory processing complete for {inventory.character_name}: {processed_count} processed, {error_count} errors, {len(inventory.items)} total items received") + return ProcessingStats( processed_count=processed_count, error_count=error_count, character_name=inventory.character_name, timestamp=inventory.timestamp, - errors=processing_errors if processing_errors else None, + errors=processing_errors if processing_errors else None ) -@app.post( - "/inventory/{character_name}/item", - summary="Upsert a single inventory item", - tags=["Data Processing"], -) +@app.post("/inventory/{character_name}/item", + summary="Upsert a single inventory item", + tags=["Data Processing"]) async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): """Process and upsert a single item for a character's inventory.""" - item_game_id = item.get("Id") or item.get("id") + item_game_id = item.get('Id') or item.get('id') if item_game_id is None: - raise HTTPException( - status_code=400, detail="Item must have an 'Id' or 'id' field" - ) + raise HTTPException(status_code=400, detail="Item must have an 'Id' or 'id' field") processed_count = 0 error_count = 0 @@ -1955,223 +1569,154 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): # Delete existing item with this character_name + item_id from all related tables existing = await database.fetch_all( "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id}, + {"character_name": character_name, "item_id": item_game_id} ) if existing: - db_ids = [row["id"] for row in existing] - for table in ( - "item_raw_data", - "item_combat_stats", - "item_requirements", - "item_enhancements", - "item_ratings", - "item_spells", - ): + db_ids = [row['id'] for row in existing] + for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', + 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} + sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + {"ids": db_ids} ) await database.execute( "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id}, + {"character_name": character_name, "item_id": item_game_id} ) # Process and insert the single item using the same logic as process_inventory try: properties = extract_item_properties(item) - basic = properties["basic"] + basic = properties['basic'] timestamp = datetime.utcnow() - item_stmt = ( - sa.insert(Item) - .values( - character_name=character_name, - item_id=item_game_id, - timestamp=timestamp, - name=basic["name"], - icon=basic["icon"], - object_class=basic["object_class"], - value=basic["value"], - burden=basic["burden"], - has_id_data=basic["has_id_data"], - last_id_time=item.get("LastIdTime", 0), - # Equipment status - current_wielded_location=basic["current_wielded_location"], - # Container/position tracking - container_id=item.get("ContainerId", 0), - slot=int( - item.get("IntValues", {}).get( - "231735296", item.get("IntValues", {}).get(231735296, -1) - ) - ), - # Item state - bonded=basic["bonded"], - attuned=basic["attuned"], - unique=basic["unique"], - # Stack/Container properties - stack_size=basic["stack_size"], - max_stack_size=basic["max_stack_size"], - items_capacity=basic["items_capacity"] - if basic["items_capacity"] != -1 - else None, - containers_capacity=basic["containers_capacity"] - if basic["containers_capacity"] != -1 - else None, - # Durability - structure=basic["structure"] if basic["structure"] != -1 else None, - max_structure=basic["max_structure"] - if basic["max_structure"] != -1 - else None, - # Special item flags - rare_id=basic["rare_id"] if basic["rare_id"] != -1 else None, - lifespan=basic["lifespan"] if basic["lifespan"] != -1 else None, - remaining_lifespan=basic["remaining_lifespan"] - if basic["remaining_lifespan"] != -1 - else None, - ) - .returning(Item.id) - ) + item_stmt = sa.insert(Item).values( + character_name=character_name, + item_id=item_game_id, + timestamp=timestamp, + name=basic['name'], + icon=basic['icon'], + object_class=basic['object_class'], + value=basic['value'], + burden=basic['burden'], + has_id_data=basic['has_id_data'], + last_id_time=item.get('LastIdTime', 0), + + # Equipment status + current_wielded_location=basic['current_wielded_location'], + + # Container/position tracking + container_id=item.get('ContainerId', 0), + slot=int(item.get('IntValues', {}).get('231735296', item.get('IntValues', {}).get(231735296, -1))), + + # Item state + bonded=basic['bonded'], + attuned=basic['attuned'], + unique=basic['unique'], + + # Stack/Container properties + stack_size=basic['stack_size'], + max_stack_size=basic['max_stack_size'], + items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, + containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, + + # Durability + structure=basic['structure'] if basic['structure'] != -1 else None, + max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, + + # Special item flags + rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, + lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, + remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, + ).returning(Item.id) result = await database.fetch_one(item_stmt) - db_item_id = result["id"] + db_item_id = result['id'] # Store combat stats if applicable - combat = properties["combat"] + combat = properties['combat'] if any(v != -1 and v != -1.0 for v in combat.values()): - combat_stmt = ( - sa.dialects.postgresql.insert(ItemCombatStats) - .values( - item_id=db_item_id, - **{ - k: v if v != -1 and v != -1.0 else None - for k, v in combat.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v != -1 and v != -1.0 else None - for k, v in combat.items() - } - ), - ) + combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( + item_id=db_item_id, + **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) ) await database.execute(combat_stmt) # Store requirements if applicable - requirements = properties["requirements"] - if any(v not in [-1, None, ""] for v in requirements.values()): - req_stmt = ( - sa.dialects.postgresql.insert(ItemRequirements) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, None, ""] else None - for k, v in requirements.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, None, ""] else None - for k, v in requirements.items() - } - ), - ) + requirements = properties['requirements'] + if any(v not in [-1, None, ''] for v in requirements.values()): + req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( + item_id=db_item_id, + **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) ) await database.execute(req_stmt) # Store enhancements - enhancements = properties["enhancements"] - enh_stmt = ( - sa.dialects.postgresql.insert(ItemEnhancements) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, -1.0, None, ""] else None - for k, v in enhancements.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, -1.0, None, ""] else None - for k, v in enhancements.items() - } - ), - ) + enhancements = properties['enhancements'] + enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) ) await database.execute(enh_stmt) # Store ratings if applicable - ratings = properties["ratings"] + ratings = properties['ratings'] if any(v not in [-1, -1.0, None] for v in ratings.values()): - rat_stmt = ( - sa.dialects.postgresql.insert(ItemRatings) - .values( - item_id=db_item_id, - **{ - k: v if v not in [-1, -1.0, None] else None - for k, v in ratings.items() - }, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - **{ - k: v if v not in [-1, -1.0, None] else None - for k, v in ratings.items() - } - ), - ) + rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) ) await database.execute(rat_stmt) # Store spell data if applicable - spells = item.get("Spells", []) - active_spells = item.get("ActiveSpells", []) + spells = item.get('Spells', []) + active_spells = item.get('ActiveSpells', []) all_spells = set(spells + active_spells) if all_spells: await database.execute( "DELETE FROM item_spells WHERE item_id = :item_id", - {"item_id": db_item_id}, + {"item_id": db_item_id} ) for spell_id in all_spells: is_active = spell_id in active_spells - spell_stmt = ( - sa.dialects.postgresql.insert(ItemSpells) - .values( - item_id=db_item_id, spell_id=spell_id, is_active=is_active - ) - .on_conflict_do_nothing() - ) + spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( + item_id=db_item_id, + spell_id=spell_id, + is_active=is_active + ).on_conflict_do_nothing() await database.execute(spell_stmt) # Store raw data - raw_stmt = ( - sa.dialects.postgresql.insert(ItemRawData) - .values( - item_id=db_item_id, - int_values=item.get("IntValues", {}), - double_values=item.get("DoubleValues", {}), - string_values=item.get("StringValues", {}), - bool_values=item.get("BoolValues", {}), - original_json=item, - ) - .on_conflict_do_update( - index_elements=["item_id"], - set_=dict( - int_values=item.get("IntValues", {}), - double_values=item.get("DoubleValues", {}), - string_values=item.get("StringValues", {}), - bool_values=item.get("BoolValues", {}), - original_json=item, - ), + raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( + item_id=db_item_id, + int_values=item.get('IntValues', {}), + double_values=item.get('DoubleValues', {}), + string_values=item.get('StringValues', {}), + bool_values=item.get('BoolValues', {}), + original_json=item + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict( + int_values=item.get('IntValues', {}), + double_values=item.get('DoubleValues', {}), + string_values=item.get('StringValues', {}), + bool_values=item.get('BoolValues', {}), + original_json=item ) ) await database.execute(raw_stmt) @@ -2184,45 +1729,13 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): error_count = 1 raise HTTPException(status_code=500, detail=error_msg) - logger.info( - f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}" - ) - - # Fetch the just-upserted item with all joins and enrich it - enriched_item = None - try: - enrich_query = """ - SELECT - i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden, - i.has_id_data, i.timestamp, - i.current_wielded_location, i.container_id, i.items_capacity, - cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus, - r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement, - e.material, e.imbue, e.item_set, e.tinks, e.workmanship, - rt.damage_rating, rt.crit_rating, rt.heal_boost_rating, - rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json - FROM items i - LEFT JOIN item_combat_stats cs ON i.id = cs.item_id - LEFT JOIN item_requirements r ON i.id = r.item_id - LEFT JOIN item_enhancements e ON i.id = e.item_id - LEFT JOIN item_ratings rt ON i.id = rt.item_id - LEFT JOIN item_raw_data rd ON i.id = rd.item_id - WHERE i.id = :db_id - """ - row = await database.fetch_one(enrich_query, {"db_id": db_item_id}) - if row: - enriched_item = enrich_db_item(row) - except Exception as e: - logger.warning(f"Failed to enrich item after upsert: {e}") - - return {"status": "ok", "processed": processed_count, "item": enriched_item} + logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}") + return {"status": "ok", "processed": processed_count} -@app.delete( - "/inventory/{character_name}/item/{item_id}", - summary="Delete a single inventory item", - tags=["Data Processing"], -) +@app.delete("/inventory/{character_name}/item/{item_id}", + summary="Delete a single inventory item", + tags=["Data Processing"]) async def delete_inventory_item(character_name: str, item_id: int): """Delete a single item from a character's inventory.""" @@ -2232,333 +1745,47 @@ async def delete_inventory_item(character_name: str, item_id: int): # Find all DB rows for this character + game item_id existing = await database.fetch_all( "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id}, + {"character_name": character_name, "item_id": item_id} ) if existing: - db_ids = [row["id"] for row in existing] + db_ids = [row['id'] for row in existing] # Delete from all related tables first - for table in ( - "item_raw_data", - "item_combat_stats", - "item_requirements", - "item_enhancements", - "item_ratings", - "item_spells", - ): + for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', + 'item_enhancements', 'item_ratings', 'item_spells'): await database.execute( - f"DELETE FROM {table} WHERE item_id = ANY(:ids)", {"ids": db_ids} + sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + {"ids": db_ids} ) # Delete from main items table await database.execute( "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id}, + {"character_name": character_name, "item_id": item_id} ) deleted_count = len(existing) - logger.info( - f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}" - ) + logger.info(f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}") return {"status": "ok", "deleted": deleted_count} -def enrich_db_item(item) -> dict: - """Enrich a single DB row (from the JOIN query) into the full frontend format. - - Takes a mapping (e.g. asyncpg Record or dict) from the items+joins query and returns - a clean dict with translated material names, spell info, combat stats, ratings, - workmanship text, mana display, etc. Identical logic to what get_character_inventory - used inline — extracted here so upsert_inventory_item can reuse it. - """ - processed_item = dict(item) - original_json = {} - - # Get comprehensive translations from original_json - if processed_item.get("original_json"): - original_json = processed_item["original_json"] - # Handle case where original_json might be stored as string - if isinstance(original_json, str): - try: - original_json = json.loads(original_json) - except (json.JSONDecodeError, TypeError): - original_json = {} - - if original_json: - if processed_item.get("active_item_enchantments"): - original_json["ActiveItemEnchantments"] = processed_item[ - "active_item_enchantments" - ] - - # Extract properties and get translations - properties = extract_item_properties(original_json) - - # Add translated properties to the item - processed_item["translated_properties"] = properties - - # Add material name - use material directly if it's already a string - if processed_item.get("material"): - if isinstance(processed_item["material"], str): - processed_item["material_name"] = processed_item["material"] - else: - processed_item["material_name"] = translate_material_type( - processed_item["material"] - ) - - # Add object class translation - if processed_item.get("object_class"): - processed_item["object_class_name"] = translate_object_class( - processed_item["object_class"], original_json - ) - - # Add skill translation - if processed_item.get("equip_skill"): - processed_item["equip_skill_name"] = translate_skill( - processed_item["equip_skill"] - ) - - # Flatten the structure - move translated properties to top level - if "translated_properties" in processed_item: - translated_props = processed_item.pop("translated_properties") - - # Move spells to top level - if "spells" in translated_props: - processed_item["spells"] = translated_props["spells"] - - # Move translated_ints to top level for enhanced tooltips - if "translated_ints" in translated_props: - processed_item["enhanced_properties"] = translated_props["translated_ints"] - - # Add weapon damage information - if "weapon_damage" in translated_props: - weapon_damage = translated_props["weapon_damage"] - if weapon_damage.get("damage_range"): - processed_item["damage_range"] = weapon_damage["damage_range"] - if weapon_damage.get("damage_type"): - processed_item["damage_type"] = weapon_damage["damage_type"] - if weapon_damage.get("min_damage"): - processed_item["min_damage"] = weapon_damage["min_damage"] - - # Add weapon speed information - if "weapon_speed" in translated_props: - speed_info = translated_props["weapon_speed"] - if speed_info.get("speed_text"): - processed_item["speed_text"] = speed_info["speed_text"] - if speed_info.get("speed_value"): - processed_item["speed_value"] = speed_info["speed_value"] - - # Add mana and spellcraft information - if "mana_info" in translated_props: - mana_info = translated_props["mana_info"] - if mana_info.get("mana_display"): - processed_item["mana_display"] = mana_info["mana_display"] - if mana_info.get("spellcraft"): - processed_item["spellcraft"] = mana_info["spellcraft"] - if mana_info.get("current_mana") is not None: - processed_item["current_mana"] = mana_info["current_mana"] - if mana_info.get("max_mana") is not None: - processed_item["max_mana"] = mana_info["max_mana"] - - mana_tracker = get_mana_tracker_info(original_json) - if mana_tracker.get("mana_state"): - processed_item["mana_state"] = mana_tracker["mana_state"] - if mana_tracker.get("mana_rate_of_change") is not None: - processed_item["mana_rate_of_change"] = mana_tracker[ - "mana_rate_of_change" - ] - if mana_tracker.get("seconds_per_burn") is not None: - processed_item["seconds_per_burn"] = mana_tracker["seconds_per_burn"] - if mana_tracker.get("mana_time_remaining_seconds") is not None: - processed_item["mana_time_remaining_seconds"] = mana_tracker[ - "mana_time_remaining_seconds" - ] - if mana_tracker.get("mana_time_remaining_display"): - processed_item["mana_time_remaining_display"] = mana_tracker[ - "mana_time_remaining_display" - ] - if mana_tracker.get("mana_snapshot_utc"): - processed_item["mana_snapshot_utc"] = mana_tracker["mana_snapshot_utc"] - if mana_tracker.get("is_mana_tracked") is not None: - processed_item["is_mana_tracked"] = mana_tracker["is_mana_tracked"] - - # Add icon overlay/underlay information from translated properties - if "translated_ints" in translated_props: - translated_ints = translated_props["translated_ints"] - - # Icon overlay - check for the proper property name - if ( - "IconOverlay_Decal_DID" in translated_ints - and translated_ints["IconOverlay_Decal_DID"] > 0 - ): - processed_item["icon_overlay_id"] = translated_ints[ - "IconOverlay_Decal_DID" - ] - - # Icon underlay - check for the proper property name - if ( - "IconUnderlay_Decal_DID" in translated_ints - and translated_ints["IconUnderlay_Decal_DID"] > 0 - ): - processed_item["icon_underlay_id"] = translated_ints[ - "IconUnderlay_Decal_DID" - ] - - # Add comprehensive combat stats - if "combat" in translated_props: - combat_stats = translated_props["combat"] - if combat_stats.get("max_damage", -1) != -1: - processed_item["max_damage"] = combat_stats["max_damage"] - if combat_stats.get("armor_level", -1) != -1: - processed_item["armor_level"] = combat_stats["armor_level"] - if combat_stats.get("damage_bonus", -1.0) != -1.0: - processed_item["damage_bonus"] = combat_stats["damage_bonus"] - if combat_stats.get("attack_bonus", -1.0) != -1.0: - processed_item["attack_bonus"] = combat_stats["attack_bonus"] - # Add missing combat bonuses - if combat_stats.get("melee_defense_bonus", -1.0) != -1.0: - processed_item["melee_defense_bonus"] = combat_stats[ - "melee_defense_bonus" - ] - if combat_stats.get("magic_defense_bonus", -1.0) != -1.0: - processed_item["magic_defense_bonus"] = combat_stats[ - "magic_defense_bonus" - ] - if combat_stats.get("missile_defense_bonus", -1.0) != -1.0: - processed_item["missile_defense_bonus"] = combat_stats[ - "missile_defense_bonus" - ] - if combat_stats.get("elemental_damage_vs_monsters", -1.0) != -1.0: - processed_item["elemental_damage_vs_monsters"] = combat_stats[ - "elemental_damage_vs_monsters" - ] - if combat_stats.get("mana_conversion_bonus", -1.0) != -1.0: - processed_item["mana_conversion_bonus"] = combat_stats[ - "mana_conversion_bonus" - ] - - # Add comprehensive requirements - if "requirements" in translated_props: - requirements = translated_props["requirements"] - if requirements.get("wield_level", -1) != -1: - processed_item["wield_level"] = requirements["wield_level"] - if requirements.get("skill_level", -1) != -1: - processed_item["skill_level"] = requirements["skill_level"] - if requirements.get("lore_requirement", -1) != -1: - processed_item["lore_requirement"] = requirements["lore_requirement"] - if requirements.get("equip_skill"): - processed_item["equip_skill"] = requirements["equip_skill"] - - # Add comprehensive enhancements - if "enhancements" in translated_props: - enhancements = translated_props["enhancements"] - if enhancements.get("material"): - processed_item["material"] = enhancements["material"] - - # Add material information from translations - if "translations" in translated_props: - trans = translated_props["translations"] - if trans.get("material_name"): - processed_item["material_name"] = trans["material_name"] - if trans.get("material_id"): - processed_item["material_id"] = trans["material_id"] - if trans.get("item_type_name"): - processed_item["item_type_name"] = trans["item_type_name"] - - # Continue with other enhancements - if "enhancements" in translated_props: - enhancements = translated_props["enhancements"] - if enhancements.get("imbue"): - processed_item["imbue"] = enhancements["imbue"] - if enhancements.get("tinks", -1) != -1: - processed_item["tinks"] = enhancements["tinks"] - if enhancements.get("workmanship", -1.0) != -1.0: - processed_item["workmanship"] = enhancements["workmanship"] - processed_item["workmanship_text"] = translate_workmanship( - int(enhancements["workmanship"]) - ) - if enhancements.get("item_set"): - processed_item["item_set"] = enhancements["item_set"] - # Add equipment set name translation - set_id = str(enhancements["item_set"]).strip() - dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) - attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get( - "values", {} - ) - if set_id in attribute_set_info: - processed_item["item_set_name"] = attribute_set_info[set_id] - else: - processed_item["item_set_name"] = f"Set {set_id}" - - # Add comprehensive ratings (use gear totals as fallback for armor/clothing) - if "ratings" in translated_props: - ratings = translated_props["ratings"] - - # Damage rating: use individual rating or gear total - if ratings.get("damage_rating", -1) != -1: - processed_item["damage_rating"] = ratings["damage_rating"] - elif ratings.get("gear_damage", -1) > 0: - processed_item["damage_rating"] = ratings["gear_damage"] - - # Crit rating - if ratings.get("crit_rating", -1) != -1: - processed_item["crit_rating"] = ratings["crit_rating"] - elif ratings.get("gear_crit", -1) > 0: - processed_item["crit_rating"] = ratings["gear_crit"] - - # Crit damage rating: use individual rating or gear total - if ratings.get("crit_damage_rating", -1) != -1: - processed_item["crit_damage_rating"] = ratings["crit_damage_rating"] - elif ratings.get("gear_crit_damage", -1) > 0: - processed_item["crit_damage_rating"] = ratings["gear_crit_damage"] - - # Heal boost rating: use individual rating or gear total - if ratings.get("heal_boost_rating", -1) != -1: - processed_item["heal_boost_rating"] = ratings["heal_boost_rating"] - elif ratings.get("gear_healing_boost", -1) > 0: - processed_item["heal_boost_rating"] = ratings["gear_healing_boost"] - - # Apply material prefix to item name if material exists - if processed_item.get("material_name") and processed_item.get("name"): - original_name = processed_item["name"] - material_name = processed_item["material_name"] - - # Don't add prefix if name already starts with the material - if not original_name.lower().startswith(material_name.lower()): - processed_item["name"] = f"{material_name} {original_name}" - processed_item["original_name"] = ( - original_name # Preserve original for reference - ) - - # Remove raw data from response (keep clean output) - processed_item.pop("int_values", None) - processed_item.pop("double_values", None) - processed_item.pop("string_values", None) - processed_item.pop("bool_values", None) - processed_item.pop("original_json", None) - - # Remove null values for cleaner response - processed_item = {k: v for k, v in processed_item.items() if v is not None} - return processed_item - - -@app.get( - "/inventory/{character_name}", - summary="Get Character Inventory", - description="Retrieve processed inventory data for a specific character with normalized item properties.", - tags=["Character Data"], -) +@app.get("/inventory/{character_name}", + 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), offset: int = Query(0, ge=0) + character_name: str, + limit: int = Query(1000, le=5000), + offset: int = Query(0, ge=0) ): """Get processed inventory for a character with structured data and comprehensive translations.""" - + query = """ SELECT - i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden, + i.id, i.name, i.icon, i.object_class, i.value, i.burden, i.has_id_data, i.timestamp, - i.current_wielded_location, i.container_id, i.items_capacity, cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus, r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement, e.material, e.imbue, e.item_set, e.tinks, e.workmanship, @@ -2574,33 +1801,239 @@ async def get_character_inventory( ORDER BY i.name LIMIT :limit OFFSET :offset """ - - items = await database.fetch_all( - query, {"character_name": character_name, "limit": limit, "offset": offset} - ) - + + items = await database.fetch_all(query, { + "character_name": character_name, + "limit": limit, + "offset": offset + }) + if not items: - raise HTTPException( - status_code=404, detail=f"No inventory found for {character_name}" - ) - + raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}") + # Convert to structured format with enhanced translations - processed_items = [enrich_db_item(item) for item in items] - + processed_items = [] + for item in items: + processed_item = dict(item) + + # Get comprehensive translations from original_json + if processed_item.get('original_json'): + original_json = processed_item['original_json'] + # Handle case where original_json might be stored as string + if isinstance(original_json, str): + try: + original_json = json.loads(original_json) + except (json.JSONDecodeError, TypeError): + original_json = {} + + if original_json: + # Extract properties and get translations + properties = extract_item_properties(original_json) + + # Add translated properties to the item + processed_item['translated_properties'] = properties + + # Add material name - use material directly if it's already a string + if processed_item.get('material'): + if isinstance(processed_item['material'], str): + processed_item['material_name'] = processed_item['material'] + else: + processed_item['material_name'] = translate_material_type(processed_item['material']) + + # Add object class translation + if processed_item.get('object_class'): + processed_item['object_class_name'] = translate_object_class(processed_item['object_class'], original_json) + + # Add skill translation + if processed_item.get('equip_skill'): + processed_item['equip_skill_name'] = translate_skill(processed_item['equip_skill']) + + # Flatten the structure - move translated properties to top level + if 'translated_properties' in processed_item: + translated_props = processed_item.pop('translated_properties') + + # Move spells to top level + if 'spells' in translated_props: + processed_item['spells'] = translated_props['spells'] + + # Move translated_ints to top level for enhanced tooltips + if 'translated_ints' in translated_props: + processed_item['enhanced_properties'] = translated_props['translated_ints'] + + # Add weapon damage information + if 'weapon_damage' in translated_props: + weapon_damage = translated_props['weapon_damage'] + if weapon_damage.get('damage_range'): + processed_item['damage_range'] = weapon_damage['damage_range'] + if weapon_damage.get('damage_type'): + processed_item['damage_type'] = weapon_damage['damage_type'] + if weapon_damage.get('min_damage'): + processed_item['min_damage'] = weapon_damage['min_damage'] + + # Add weapon speed information + if 'weapon_speed' in translated_props: + speed_info = translated_props['weapon_speed'] + if speed_info.get('speed_text'): + processed_item['speed_text'] = speed_info['speed_text'] + if speed_info.get('speed_value'): + processed_item['speed_value'] = speed_info['speed_value'] + + # Add mana and spellcraft information + if 'mana_info' in translated_props: + mana_info = translated_props['mana_info'] + if mana_info.get('mana_display'): + processed_item['mana_display'] = mana_info['mana_display'] + if mana_info.get('spellcraft'): + processed_item['spellcraft'] = mana_info['spellcraft'] + if mana_info.get('current_mana'): + processed_item['current_mana'] = mana_info['current_mana'] + if mana_info.get('max_mana'): + processed_item['max_mana'] = mana_info['max_mana'] + + # Add icon overlay/underlay information from translated properties + if 'translated_ints' in translated_props: + translated_ints = translated_props['translated_ints'] + + # Icon overlay - check for the proper property name + if 'IconOverlay_Decal_DID' in translated_ints and translated_ints['IconOverlay_Decal_DID'] > 0: + processed_item['icon_overlay_id'] = translated_ints['IconOverlay_Decal_DID'] + + # Icon underlay - check for the proper property name + if 'IconUnderlay_Decal_DID' in translated_ints and translated_ints['IconUnderlay_Decal_DID'] > 0: + processed_item['icon_underlay_id'] = translated_ints['IconUnderlay_Decal_DID'] + + # Add comprehensive combat stats + if 'combat' in translated_props: + combat_stats = translated_props['combat'] + if combat_stats.get('max_damage', -1) != -1: + processed_item['max_damage'] = combat_stats['max_damage'] + if combat_stats.get('armor_level', -1) != -1: + processed_item['armor_level'] = combat_stats['armor_level'] + if combat_stats.get('damage_bonus', -1.0) != -1.0: + processed_item['damage_bonus'] = combat_stats['damage_bonus'] + if combat_stats.get('attack_bonus', -1.0) != -1.0: + processed_item['attack_bonus'] = combat_stats['attack_bonus'] + # Add missing combat bonuses + if combat_stats.get('melee_defense_bonus', -1.0) != -1.0: + processed_item['melee_defense_bonus'] = combat_stats['melee_defense_bonus'] + if combat_stats.get('magic_defense_bonus', -1.0) != -1.0: + processed_item['magic_defense_bonus'] = combat_stats['magic_defense_bonus'] + if combat_stats.get('missile_defense_bonus', -1.0) != -1.0: + processed_item['missile_defense_bonus'] = combat_stats['missile_defense_bonus'] + if combat_stats.get('elemental_damage_vs_monsters', -1.0) != -1.0: + processed_item['elemental_damage_vs_monsters'] = combat_stats['elemental_damage_vs_monsters'] + if combat_stats.get('mana_conversion_bonus', -1.0) != -1.0: + processed_item['mana_conversion_bonus'] = combat_stats['mana_conversion_bonus'] + + # Add comprehensive requirements + if 'requirements' in translated_props: + requirements = translated_props['requirements'] + if requirements.get('wield_level', -1) != -1: + processed_item['wield_level'] = requirements['wield_level'] + if requirements.get('skill_level', -1) != -1: + processed_item['skill_level'] = requirements['skill_level'] + if requirements.get('lore_requirement', -1) != -1: + processed_item['lore_requirement'] = requirements['lore_requirement'] + if requirements.get('equip_skill'): + processed_item['equip_skill'] = requirements['equip_skill'] + + # Add comprehensive enhancements + if 'enhancements' in translated_props: + enhancements = translated_props['enhancements'] + if enhancements.get('material'): + processed_item['material'] = enhancements['material'] + + # Add material information from translations + if 'translations' in translated_props: + trans = translated_props['translations'] + if trans.get('material_name'): + processed_item['material_name'] = trans['material_name'] + if trans.get('material_id'): + processed_item['material_id'] = trans['material_id'] + if trans.get('item_type_name'): + processed_item['item_type_name'] = trans['item_type_name'] + + # Continue with other enhancements + if 'enhancements' in translated_props: + enhancements = translated_props['enhancements'] + if enhancements.get('imbue'): + processed_item['imbue'] = enhancements['imbue'] + if enhancements.get('tinks', -1) != -1: + processed_item['tinks'] = enhancements['tinks'] + if enhancements.get('workmanship', -1.0) != -1.0: + processed_item['workmanship'] = enhancements['workmanship'] + processed_item['workmanship_text'] = translate_workmanship(int(enhancements['workmanship'])) + if enhancements.get('item_set'): + processed_item['item_set'] = enhancements['item_set'] + # Add equipment set name translation + set_id = str(enhancements['item_set']).strip() + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + if set_id in attribute_set_info: + processed_item['item_set_name'] = attribute_set_info[set_id] + else: + processed_item['item_set_name'] = f"Set {set_id}" + + # Add comprehensive ratings (use gear totals as fallback for armor/clothing) + if 'ratings' in translated_props: + ratings = translated_props['ratings'] + + # Damage rating: use individual rating or gear total + if ratings.get('damage_rating', -1) != -1: + processed_item['damage_rating'] = ratings['damage_rating'] + elif ratings.get('gear_damage', -1) > 0: + processed_item['damage_rating'] = ratings['gear_damage'] + + # Crit rating + if ratings.get('crit_rating', -1) != -1: + processed_item['crit_rating'] = ratings['crit_rating'] + elif ratings.get('gear_crit', -1) > 0: + processed_item['crit_rating'] = ratings['gear_crit'] + + # Crit damage rating: use individual rating or gear total + if ratings.get('crit_damage_rating', -1) != -1: + processed_item['crit_damage_rating'] = ratings['crit_damage_rating'] + elif ratings.get('gear_crit_damage', -1) > 0: + processed_item['crit_damage_rating'] = ratings['gear_crit_damage'] + + # Heal boost rating: use individual rating or gear total + if ratings.get('heal_boost_rating', -1) != -1: + processed_item['heal_boost_rating'] = ratings['heal_boost_rating'] + elif ratings.get('gear_healing_boost', -1) > 0: + processed_item['heal_boost_rating'] = ratings['gear_healing_boost'] + + # Apply material prefix to item name if material exists + if processed_item.get('material_name') and processed_item.get('name'): + original_name = processed_item['name'] + material_name = processed_item['material_name'] + + # Don't add prefix if name already starts with the material + if not original_name.lower().startswith(material_name.lower()): + processed_item['name'] = f"{material_name} {original_name}" + processed_item['original_name'] = original_name # Preserve original for reference + + # Remove raw data from response (keep clean output) + processed_item.pop('int_values', None) + processed_item.pop('double_values', None) + processed_item.pop('string_values', None) + processed_item.pop('bool_values', None) + processed_item.pop('original_json', None) + + # Remove null values for cleaner response + processed_item = {k: v for k, v in processed_item.items() if v is not None} + processed_items.append(processed_item) + return { "character_name": character_name, "item_count": len(processed_items), - "items": processed_items, + "items": processed_items } - -@app.get( - "/health", - response_model=HealthResponse, - summary="Service Health Check", - description="Returns service health status, database connectivity, and version information.", - tags=["System"], -) +@app.get("/health", + 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 with comprehensive status information.""" try: @@ -2609,20 +2042,18 @@ async def health_check(): 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", + version="1.0.0" ) - -@app.get( - "/sets/list", - response_model=SetListResponse, - summary="List Equipment Sets", - description=""" +@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 @@ -2637,8 +2068,7 @@ async def health_check(): - **40**: Heroic Protector - **41**: Heroic Destroyer """, - tags=["Equipment Sets"], -) + tags=["Equipment Sets"]) async def list_equipment_sets(): """Get all unique equipment set names with item counts from the database.""" try: @@ -2651,66 +2081,63 @@ async def list_equipment_sets(): ORDER BY item_count DESC, enh.item_set """ rows = await database.fetch_all(query) - + # Get AttributeSetInfo mapping from enum database - attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get("values", {}) - + attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) + # Map equipment sets to proper names equipment_sets = [] for row in rows: set_id = row["item_set"] item_count = row["item_count"] - + # Get proper name from AttributeSetInfo mapping set_name = attribute_set_info.get(set_id, f"Unknown Set {set_id}") - - equipment_sets.append( - {"id": set_id, "name": set_name, "item_count": item_count} - ) - - return SetListResponse(sets=equipment_sets) - + + equipment_sets.append({ + "id": set_id, + "name": set_name, + "item_count": item_count + }) + + 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", - summary="Get Enum Information", - description="Get comprehensive information about available enum translations and database statistics.", - tags=["System"], -) +@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: return {"error": "Enum database not loaded"} - + return { "available_enums": list(ENUM_MAPPINGS.keys()), - "int_values_count": len(ENUM_MAPPINGS.get("int_values", {})), - "materials_count": len(ENUM_MAPPINGS.get("materials", {})), - "item_types_count": len(ENUM_MAPPINGS.get("item_types", {})), - "skills_count": len(ENUM_MAPPINGS.get("skills", {})), - "spell_categories_count": len(ENUM_MAPPINGS.get("spell_categories", {})), - "spells_count": len(ENUM_MAPPINGS.get("spells", {})), - "object_classes_count": len(ENUM_MAPPINGS.get("object_classes", {})), - "coverage_masks_count": len(ENUM_MAPPINGS.get("coverage_masks", {})), - "database_version": ENUM_MAPPINGS.get("full_database", {}) - .get("metadata", {}) - .get("version", "unknown"), + "int_values_count": len(ENUM_MAPPINGS.get('int_values', {})), + "materials_count": len(ENUM_MAPPINGS.get('materials', {})), + "item_types_count": len(ENUM_MAPPINGS.get('item_types', {})), + "skills_count": len(ENUM_MAPPINGS.get('skills', {})), + "spell_categories_count": len(ENUM_MAPPINGS.get('spell_categories', {})), + "spells_count": len(ENUM_MAPPINGS.get('spells', {})), + "object_classes_count": len(ENUM_MAPPINGS.get('object_classes', {})), + "coverage_masks_count": len(ENUM_MAPPINGS.get('coverage_masks', {})), + "database_version": ENUM_MAPPINGS.get('full_database', {}).get('metadata', {}).get('version', 'unknown') } - -@app.get( - "/translate/{enum_type}/{value}", - summary="Translate Enum Value", - description="Translate a specific enum value to human-readable name using the comprehensive enum database.", - tags=["System"], -) +@app.get("/translate/{enum_type}/{value}", + 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.""" - + if enum_type == "material": return {"translation": translate_material_type(value)} elif enum_type == "item_type": @@ -2722,16 +2149,15 @@ async def translate_enum_value(enum_type: str, value: int): elif enum_type == "spell": return {"translation": translate_spell(value)} elif enum_type == "int_value": - int_enums = ENUM_MAPPINGS.get("int_values", {}) + int_enums = ENUM_MAPPINGS.get('int_values', {}) return {"translation": int_enums.get(value, f"unknown_{value}")} else: raise HTTPException(status_code=400, detail=f"Unknown enum type: {enum_type}") - @app.get("/inventory/{character_name}/raw") async def get_character_inventory_raw(character_name: str): """Get raw inventory data including comprehensive translations.""" - + query = """ SELECT i.name, i.icon, i.object_class, i.value, i.burden, i.timestamp, @@ -2743,22 +2169,20 @@ async def get_character_inventory_raw(character_name: str): ORDER BY i.name LIMIT 100 """ - + items = await database.fetch_all(query, {"character_name": character_name}) - + if not items: - raise HTTPException( - status_code=404, detail=f"No inventory found for {character_name}" - ) - + raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}") + # Process items with comprehensive translations processed_items = [] for item in items: item_dict = dict(item) - + # Add comprehensive translations for raw data - if item_dict.get("original_json"): - original_json = item_dict["original_json"] + if item_dict.get('original_json'): + original_json = item_dict['original_json'] # Handle case where original_json might be stored as string if isinstance(original_json, str): try: @@ -2766,17 +2190,17 @@ async def get_character_inventory_raw(character_name: str): except (json.JSONDecodeError, TypeError): logger.warning(f"Failed to parse original_json as JSON for item") original_json = {} - + if original_json: translations = get_comprehensive_translations(original_json) - item_dict["comprehensive_translations"] = translations - + item_dict['comprehensive_translations'] = translations + processed_items.append(item_dict) - + return { "character_name": character_name, "item_count": len(processed_items), - "items": processed_items, + "items": processed_items } @@ -2784,12 +2208,10 @@ async def get_character_inventory_raw(character_name: str): # INVENTORY SEARCH API ENDPOINTS # =================================================================== - -@app.get( - "/search/items", - response_model=ItemSearchResponse, - summary="Advanced Item Search", - description=""" +@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 @@ -2817,151 +2239,88 @@ async def get_character_inventory_raw(character_name: str): Returns paginated results with item details including translated properties, combat stats, spell information, and character ownership data. """, - tags=["Search"], -) + tags=["Search"]) async def search_items( # Text search - text: str = Query( - None, - description="Search item names, descriptions, or properties", - example="Celdon", - ), - character: str = Query( - None, - description="Limit search to specific character", - example="Megamula XXXIII", - ), - characters: str = Query( - None, - description="Comma-separated list of character names", - example="Char1,Char2,Char3", - ), - include_all_characters: bool = Query( - False, description="Search across all characters" - ), - # Equipment filtering - equipment_status: str = Query( - None, - description="Filter by equipment status: 'equipped', 'unequipped', or omit for all", - example="unequipped", - ), - equipment_slot: int = Query( - None, - description="Equipment slot mask (1=head, 2=neck, 4=chest, 8=abdomen, 16=upper_arms, 32=lower_arms, 64=hands, 128=upper_legs, 256=lower_legs, 512=feet, 1024=chest2, 2048=bracelet, 4096=ring)", - example=4, - ), - slot_names: str = Query( - None, - description="Comma-separated list of slot names", - example="Head,Chest,Ring", - ), + 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="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)" - ), + 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)" - ), + underwear_only: bool = Query(False, description="Show all underclothes (shirts and pants)"), + # Spell filtering - has_spell: str = Query( - None, - description="Must have this specific spell (by name)", - example="Legendary Strength", - ), - spell_contains: str = Query( - None, description="Spell name contains this text", example="Legendary" - ), - legendary_cantrips: str = Query( - None, - description="Comma-separated list of legendary cantrip names", - example="Legendary Strength,Legendary Endurance", - ), + has_spell: str = Query(None, description="Must have this specific spell (by name)", 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"), 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 (0-2)", example=2 - ), - min_damage_rating: int = Query( - None, description="Minimum damage rating (0-3)", example=3 - ), + min_crit_damage_rating: int = Query(None, description="Minimum critical damage rating (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" - ), + min_damage_resist_rating: int = Query(None, description="Minimum damage resist rating"), min_crit_resist_rating: int = Query(None, description="Minimum crit resist rating"), - min_crit_damage_resist_rating: int = Query( - None, description="Minimum crit damage resist rating" - ), - min_healing_resist_rating: int = Query( - None, description="Minimum healing resist rating" - ), - min_nether_resist_rating: int = Query( - None, description="Minimum nether resist rating" - ), + min_crit_damage_resist_rating: int = Query(None, description="Minimum crit damage resist rating"), + min_healing_resist_rating: int = Query(None, description="Minimum healing resist rating"), + min_nether_resist_rating: int = Query(None, description="Minimum nether resist rating"), min_healing_rating: int = Query(None, description="Minimum healing rating"), min_dot_resist_rating: int = Query(None, description="Minimum DoT resist rating"), min_life_resist_rating: int = Query(None, description="Minimum life resist rating"), - min_sneak_attack_rating: int = Query( - None, description="Minimum sneak attack rating" - ), - min_recklessness_rating: int = Query( - None, description="Minimum recklessness rating" - ), + min_sneak_attack_rating: int = Query(None, description="Minimum sneak attack rating"), + min_recklessness_rating: int = Query(None, description="Minimum recklessness rating"), min_deception_rating: int = Query(None, description="Minimum deception rating"), min_pk_damage_rating: int = Query(None, description="Minimum PvP damage rating"), - min_pk_damage_resist_rating: int = Query( - None, description="Minimum PvP damage resist rating" - ), - min_gear_pk_damage_rating: int = Query( - None, description="Minimum gear PvP damage rating" - ), - min_gear_pk_damage_resist_rating: int = Query( - None, description="Minimum gear PvP damage resist rating" - ), + min_pk_damage_resist_rating: int = Query(None, description="Minimum PvP damage resist rating"), + min_gear_pk_damage_rating: int = Query(None, description="Minimum gear PvP damage rating"), + min_gear_pk_damage_resist_rating: int = Query(None, description="Minimum gear PvP damage resist rating"), + # Requirements max_level: int = Query(None, description="Maximum wield level requirement"), min_level: int = Query(None, description="Minimum wield level requirement"), + # Enhancements - material: str = Query( - None, description="Material type (partial match)", example="Gold" - ), - min_workmanship: float = Query( - None, description="Minimum workmanship", example=9.5 - ), + material: str = Query(None, description="Material type (partial match)", 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)", example="13"), - item_sets: str = Query( - None, description="Comma-separated list of item set IDs", example="13,14,42" - ), + 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"), 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"), + # Value/utility 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"), + # Sorting and pagination - sort_by: str = Query( - "name", - description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level", - ), + sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"), sort_dir: str = Query("asc", description="Sort direction: asc or desc"), page: int = Query(1, ge=1, description="Page number"), - limit: int = Query(200, ge=1, le=50000, description="Items per page"), + limit: int = Query(200, ge=1, le=50000, description="Items per page") ): """ Search items across characters with comprehensive filtering options. @@ -2972,13 +2331,12 @@ async def search_items( if shirt_only: underwear_filter_type = "shirts" elif pants_only: - underwear_filter_type = "pants" + underwear_filter_type = "pants" elif underwear_only: underwear_filter_type = "all_underwear" - + # Build base query with CTE for computed slot names - query_parts = [ - """ + query_parts = [""" WITH items_with_slots AS ( SELECT DISTINCT i.id as db_item_id, @@ -3135,9 +2493,8 @@ async def search_items( LEFT JOIN item_raw_data rd ON i.id = rd.item_id ) 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 @@ -3155,7 +2512,7 @@ async def search_items( 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 + # 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 @@ -3171,26 +2528,24 @@ async def search_items( 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 )", + f"LEFT JOIN item_raw_data rd ON i.id = rd.item_id\n {cte_where_clause}\n )" ) - + conditions = [] params = {} - + # Character filtering if character: conditions.append("character_name = :character") params["character"] = character elif characters: # Handle comma-separated list of characters - character_list = [ - char.strip() for char in characters.split(",") if char.strip() - ] + character_list = [char.strip() for char in characters.split(',') if char.strip()] if character_list: # Create parameterized IN clause char_params = [] @@ -3203,16 +2558,16 @@ async def search_items( return { "error": "Empty characters list provided", "items": [], - "total_count": 0, + "total_count": 0 } elif not include_all_characters: # Default to requiring character parameter if not searching all return { "error": "Must specify character, characters, or set include_all_characters=true", "items": [], - "total_count": 0, + "total_count": 0 } - + # Text search (name with material support) if text: # Search both the concatenated material+name and base name @@ -3223,7 +2578,7 @@ async def search_items( COALESCE(material, '') ILIKE :text )""") params["text"] = f"%{text}%" - + # Item category filtering if armor_only: # Armor: ObjectClass 2 (Armor) with armor_level > 0 @@ -3233,9 +2588,7 @@ async def search_items( 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)" - ) + 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. @@ -3246,180 +2599,148 @@ async def search_items( 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 if has_spell or spell_contains or legendary_cantrips: query_parts[0] = query_parts[0].replace( "LEFT JOIN item_ratings rt ON i.id = rt.item_id", """LEFT JOIN item_ratings rt ON i.id = rt.item_id - LEFT JOIN item_spells sp ON i.id = sp.item_id""", + LEFT JOIN item_spells sp ON i.id = sp.item_id""" ) spell_join_added = True - + spell_conditions = [] - + if has_spell: # Look up spell ID by exact name match in ENUM_MAPPINGS spell_id = None - spells = ENUM_MAPPINGS.get("spells", {}) + spells = ENUM_MAPPINGS.get('spells', {}) for sid, spell_data in spells.items(): - if ( - isinstance(spell_data, dict) - and spell_data.get("name", "").lower() == has_spell.lower() - ): + if isinstance(spell_data, dict) and spell_data.get('name', '').lower() == has_spell.lower(): spell_id = sid break - + if spell_id: spell_conditions.append("sp.spell_id = :has_spell_id") params["has_spell_id"] = spell_id else: # If spell not found by exact name, no results conditions.append("1 = 0") - + if spell_contains: # Find all spell IDs that contain the text matching_spell_ids = [] - spells = ENUM_MAPPINGS.get("spells", {}) + spells = ENUM_MAPPINGS.get('spells', {}) for sid, spell_data in spells.items(): if isinstance(spell_data, dict): - spell_name = spell_data.get("name", "").lower() + spell_name = spell_data.get('name', '').lower() if spell_contains.lower() in spell_name: matching_spell_ids.append(sid) - + if matching_spell_ids: - spell_conditions.append( - f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})" - ) + spell_conditions.append(f"sp.spell_id IN ({','.join(map(str, matching_spell_ids))})") else: # If no spells found containing the text, no results conditions.append("1 = 0") - + if legendary_cantrips: # Parse comma-separated list of cantrip names - cantrip_names = [name.strip() for name in legendary_cantrips.split(",")] + cantrip_names = [name.strip() for name in legendary_cantrips.split(',')] matching_spell_ids = [] - spells = ENUM_MAPPINGS.get("spells", {}) - + spells = ENUM_MAPPINGS.get('spells', {}) + logger.info(f"Looking for cantrips: {cantrip_names}") - + for cantrip_name in cantrip_names: found_match = False for sid, spell_data in spells.items(): if isinstance(spell_data, dict): - spell_name = spell_data.get("name", "").lower() + spell_name = spell_data.get('name', '').lower() # Match cantrip name (flexible matching) - if ( - cantrip_name.lower() in spell_name - or spell_name in cantrip_name.lower() - ): + if cantrip_name.lower() in spell_name or spell_name in cantrip_name.lower(): matching_spell_ids.append(sid) found_match = True - logger.info( - f"Found spell match: {spell_name} (ID: {sid}) for cantrip: {cantrip_name}" - ) - + logger.info(f"Found spell match: {spell_name} (ID: {sid}) for cantrip: {cantrip_name}") + if not found_match: - logger.warning( - f"No spell found matching cantrip: {cantrip_name}" - ) - + logger.warning(f"No spell found matching cantrip: {cantrip_name}") + if matching_spell_ids: # Remove duplicates matching_spell_ids = list(set(matching_spell_ids)) - + # CONSTRAINT SATISFACTION: Items must have ALL selected spells (AND logic) # Use EXISTS subquery to ensure all required spells are present - num_required_spells = len( - cantrip_names - ) # Number of different cantrips user selected + num_required_spells = len(cantrip_names) # Number of different cantrips user selected spell_conditions.append(f"""( SELECT COUNT(DISTINCT sp2.spell_id) FROM item_spells sp2 WHERE sp2.item_id = db_item_id - AND sp2.spell_id IN ({",".join(map(str, matching_spell_ids))}) + AND sp2.spell_id IN ({','.join(map(str, matching_spell_ids))}) ) >= {num_required_spells}""") - logger.info( - f"Constraint satisfaction: Items must have ALL {num_required_spells} cantrips from IDs: {matching_spell_ids}" - ) + logger.info(f"Constraint satisfaction: Items must have ALL {num_required_spells} cantrips from IDs: {matching_spell_ids}") else: # If no matching cantrips found, this will return no results - logger.warning( - "No matching spells found for any cantrips - search will return empty results" - ) + logger.warning("No matching spells found for any cantrips - search will return empty results") spell_conditions.append("1 = 0") # Use impossible condition - + # Add spell constraints (now using AND logic for constraint satisfaction) if spell_conditions: conditions.extend(spell_conditions) - + # Equipment status if equipment_status == "equipped": conditions.append("current_wielded_location > 0") elif equipment_status == "unequipped": conditions.append("current_wielded_location = 0") - + # Equipment slot if equipment_slot is not None: conditions.append("current_wielded_location = :equipment_slot") params["equipment_slot"] = equipment_slot - + # Slot names filtering - use multiple approaches for better coverage if slot_names: - slot_list = [slot.strip() for slot in slot_names.split(",") if slot.strip()] + slot_list = [slot.strip() for slot in slot_names.split(',') if slot.strip()] if slot_list: slot_conditions = [] for i, slot_name in enumerate(slot_list): param_name = f"slot_{i}" - + # Multiple filtering approaches for better coverage slot_approaches = [] - + # Approach 1: Check computed_slot_name - if slot_name.lower() == "ring": + if slot_name.lower() == 'ring': slot_approaches.append("(computed_slot_name ILIKE '%Ring%')") - elif slot_name.lower() in ["bracelet", "wrist"]: + elif slot_name.lower() in ['bracelet', 'wrist']: slot_approaches.append("(computed_slot_name ILIKE '%Wrist%')") else: - slot_approaches.append( - f"(computed_slot_name ILIKE :{param_name})" - ) + slot_approaches.append(f"(computed_slot_name ILIKE :{param_name})") params[param_name] = f"%{slot_name}%" - + # Approach 2: Specific jewelry logic for common cases - if slot_name.lower() == "neck": + if slot_name.lower() == 'neck': # For neck: object_class = 4 (jewelry) with amulet/necklace/gorget names - slot_approaches.append( - "(object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%'))" - ) - elif slot_name.lower() == "ring": + slot_approaches.append("(object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%'))") + elif slot_name.lower() == 'ring': # For rings: object_class = 4 with ring in name (excluding keyrings) - slot_approaches.append( - "(object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%')" - ) - elif slot_name.lower() in ["bracelet", "wrist"]: + slot_approaches.append("(object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%')") + elif slot_name.lower() in ['bracelet', 'wrist']: # For bracelets: object_class = 4 with bracelet in name - slot_approaches.append( - "(object_class = 4 AND name ILIKE '%bracelet%')" - ) - elif slot_name.lower() == "trinket": + slot_approaches.append("(object_class = 4 AND name ILIKE '%bracelet%')") + elif slot_name.lower() == 'trinket': # For trinkets: multiple approaches # 1. Equipped trinkets have specific wielded_location slot_approaches.append("(current_wielded_location = 67108864)") # 2. Jewelry with trinket-related names - slot_approaches.append( - "(object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%'))" - ) + slot_approaches.append("(object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%'))") # 3. Gems with trinket names - slot_approaches.append( - "(object_class = 11 AND name ILIKE '%trinket%')" - ) + slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')") # 4. Jewelry fallback: items that don't match other jewelry patterns - slot_approaches.append( - "(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')" - ) - elif slot_name.lower() == "cloak": + slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')") + elif slot_name.lower() == 'cloak': # For cloaks: identify by name pattern slot_approaches.append("(name ILIKE '%cloak%')") slot_approaches.append("(computed_slot_name = 'Cloak')") @@ -3427,14 +2748,12 @@ async def search_items( # Combine approaches with OR (any approach can match) if slot_approaches: slot_conditions.append(f"({' OR '.join(slot_approaches)})") - + # Use OR logic for slots (items matching ANY selected slot) if slot_conditions: conditions.append(f"({' OR '.join(slot_conditions)})") - logger.info( - f"Slot filtering: Looking for items with slots: {slot_list}" - ) - + logger.info(f"Slot filtering: Looking for items with slots: {slot_list}") + # Combat properties if min_damage is not None: conditions.append("max_damage >= :min_damage") @@ -3470,9 +2789,7 @@ async def search_items( conditions.append("crit_resist_rating >= :min_crit_resist_rating") params["min_crit_resist_rating"] = min_crit_resist_rating if min_crit_damage_resist_rating is not None: - conditions.append( - "crit_damage_resist_rating >= :min_crit_damage_resist_rating" - ) + conditions.append("crit_damage_resist_rating >= :min_crit_damage_resist_rating") params["min_crit_damage_resist_rating"] = min_crit_damage_resist_rating if min_healing_resist_rating is not None: conditions.append("healing_resist_rating >= :min_healing_resist_rating") @@ -3508,13 +2825,9 @@ async def search_items( conditions.append("gear_pk_damage_rating >= :min_gear_pk_damage_rating") params["min_gear_pk_damage_rating"] = min_gear_pk_damage_rating if min_gear_pk_damage_resist_rating is not None: - conditions.append( - "gear_pk_damage_resist_rating >= :min_gear_pk_damage_resist_rating" - ) - params["min_gear_pk_damage_resist_rating"] = ( - min_gear_pk_damage_resist_rating - ) - + conditions.append("gear_pk_damage_resist_rating >= :min_gear_pk_damage_resist_rating") + params["min_gear_pk_damage_resist_rating"] = min_gear_pk_damage_resist_rating + # Requirements if max_level is not None: conditions.append("(wield_level <= :max_level OR wield_level IS NULL)") @@ -3522,7 +2835,7 @@ async def search_items( if min_level is not None: conditions.append("wield_level >= :min_level") params["min_level"] = min_level - + # Enhancements if material: conditions.append("material ILIKE :material") @@ -3538,31 +2851,23 @@ async def search_items( if item_set: # Translate set ID to set name for database matching set_name = translate_equipment_set_id(item_set) - logger.info( - f"Translated equipment set ID '{item_set}' to name '{set_name}'" - ) + logger.info(f"Translated equipment set ID '{item_set}' to name '{set_name}'") conditions.append("item_set = :item_set") params["item_set"] = set_name elif item_sets: # Handle comma-separated list of item set IDs - set_list = [ - set_id.strip() for set_id in item_sets.split(",") if set_id.strip() - ] + set_list = [set_id.strip() for set_id in item_sets.split(',') if set_id.strip()] if set_list: # CONSTRAINT SATISFACTION: Multiple equipment sets if len(set_list) > 1: # IMPOSSIBLE CONSTRAINT: Item cannot be in multiple sets simultaneously - logger.info( - f"Multiple equipment sets selected: {set_list} - This is impossible, returning no results" - ) + logger.info(f"Multiple equipment sets selected: {set_list} - This is impossible, returning no results") conditions.append("1 = 0") # No results for impossible constraint else: # Single set selection - normal behavior set_id = set_list[0] set_name = translate_equipment_set_id(set_id) - logger.info( - f"Translated equipment set ID '{set_id}' to name '{set_name}'" - ) + logger.info(f"Translated equipment set ID '{set_id}' to name '{set_name}'") conditions.append("item_set = :item_set") params["item_set"] = set_name else: @@ -3571,7 +2876,7 @@ async def search_items( if min_tinks is not None: conditions.append("tinks >= :min_tinks") params["min_tinks"] = min_tinks - + # Item state if bonded is not None: conditions.append("bonded > 0" if bonded else "bonded = 0") @@ -3586,11 +2891,9 @@ async def search_items( else: conditions.append("(rare_id IS NULL OR rare_id <= 0)") if min_condition is not None: - conditions.append( - "((structure * 100.0 / NULLIF(max_structure, 0)) >= :min_condition OR max_structure IS NULL)" - ) + conditions.append("((structure * 100.0 / NULLIF(max_structure, 0)) >= :min_condition OR max_structure IS NULL)") params["min_condition"] = min_condition - + # Value/utility if min_value is not None: conditions.append("value >= :min_value") @@ -3601,11 +2904,11 @@ async def search_items( if max_burden is not None: conditions.append("burden <= :max_burden") params["max_burden"] = max_burden - + # Build WHERE clause if conditions: query_parts.append("WHERE " + " AND ".join(conditions)) - + # Add ORDER BY sort_mapping = { "name": "name", @@ -3627,28 +2930,25 @@ async def search_items( "coverage": "coverage_mask", "item_type_name": "object_class", "last_updated": "timestamp", - "spell_names": "computed_spell_names", + "spell_names": "computed_spell_names" } sort_field = sort_mapping.get(sort_by, "name") sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" # Handle NULLS for optional fields nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST" - query_parts.append( - f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id" - ) - + query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id") + # Add pagination offset = (page - 1) * limit query_parts.append(f"LIMIT {limit} OFFSET {offset}") - + # Execute query query = "\n".join(query_parts) rows = await database.fetch_all(query, params) - + # Get total count for pagination - use same CTE structure - count_query_parts = [ - """ + count_query_parts = [""" WITH items_with_slots AS ( SELECT DISTINCT i.id as db_item_id, @@ -3744,269 +3044,220 @@ async def search_items( LEFT JOIN item_enhancements enh ON i.id = enh.item_id LEFT JOIN item_ratings rt ON i.id = rt.item_id LEFT JOIN item_raw_data rd ON i.id = rd.item_id - """ - ] - + """] + # Add spell join to count query if needed if spell_join_added: count_query_parts.append("LEFT JOIN item_spells sp ON i.id = sp.item_id") - + count_query_parts.append(""" ) SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots """) - + if conditions: count_query_parts.append("WHERE " + " AND ".join(conditions)) - + count_query = "\n".join(count_query_parts) - + count_result = await database.fetch_one(count_query, params) total_count = int(count_result[0]) if count_result else 0 - + # Format results with comprehensive translations (like individual inventory endpoint) items = [] for row in rows: item = dict(row) - + # Add computed properties - item["is_equipped"] = item["current_wielded_location"] > 0 - item["is_bonded"] = item["bonded"] > 0 - item["is_attuned"] = item["attuned"] > 0 - item["is_rare"] = (item["rare_id"] or 0) > 0 - + item['is_equipped'] = item['current_wielded_location'] > 0 + item['is_bonded'] = item['bonded'] > 0 + item['is_attuned'] = item['attuned'] > 0 + item['is_rare'] = (item['rare_id'] or 0) > 0 + # Calculate condition percentage - if item["max_structure"] and item["max_structure"] > 0: - item["condition_percent"] = round( - (item["structure"] or 0) * 100.0 / item["max_structure"], 1 - ) + if item['max_structure'] and item['max_structure'] > 0: + item['condition_percent'] = round((item['structure'] or 0) * 100.0 / item['max_structure'], 1) else: - item["condition_percent"] = None - + item['condition_percent'] = None + # Apply comprehensive translations from original_json (like individual inventory endpoint) - if item.get("original_json"): - original_json = item["original_json"] + if item.get('original_json'): + original_json = item['original_json'] # Handle case where original_json might be stored as string if isinstance(original_json, str): try: original_json = json.loads(original_json) except (json.JSONDecodeError, TypeError): original_json = {} - + if original_json: # Extract properties and get comprehensive translations properties = extract_item_properties(original_json) - + # Add material translation and prefixing - if item.get("material") or properties.get("translations", {}).get( - "material_name" - ): + if item.get('material') or properties.get('translations', {}).get('material_name'): material_name = None - if item.get("material"): + if item.get('material'): # Check if material is already a string or needs translation - if isinstance(item["material"], str): - material_name = item["material"] + if isinstance(item['material'], str): + material_name = item['material'] else: - material_name = translate_material_type( - item["material"] - ) - elif properties.get("translations", {}).get("material_name"): - material_name = properties["translations"]["material_name"] - - if material_name and not material_name.startswith( - "Unknown_Material_" - ): - item["material_name"] = material_name + material_name = translate_material_type(item['material']) + elif properties.get('translations', {}).get('material_name'): + material_name = properties['translations']['material_name'] + + if material_name and not material_name.startswith('Unknown_Material_'): + item['material_name'] = material_name # Apply material prefix to item name - original_name = item["name"] - if not original_name.lower().startswith( - material_name.lower() - ): - item["name"] = f"{material_name} {original_name}" - item["original_name"] = original_name - + original_name = item['name'] + if not original_name.lower().startswith(material_name.lower()): + item['name'] = f"{material_name} {original_name}" + item['original_name'] = original_name + # Add object class translation - if item.get("object_class"): - item["object_class_name"] = translate_object_class( - item["object_class"], original_json - ) - + if item.get('object_class'): + item['object_class_name'] = translate_object_class(item['object_class'], original_json) + # Add item type translation - if properties.get("translations", {}).get("item_type_name"): - item["item_type_name"] = properties["translations"][ - "item_type_name" - ] - elif item.get("object_class"): + if properties.get('translations', {}).get('item_type_name'): + item['item_type_name'] = properties['translations']['item_type_name'] + elif item.get('object_class'): # Fallback: derive ItemType from object_class when translation is missing - item["item_type_name"] = derive_item_type_from_object_class( - item["object_class"], {"Name": item.get("name", "")} - ) - + item['item_type_name'] = derive_item_type_from_object_class(item['object_class'], {'Name': item.get('name', '')}) + # Add spell information - if "spells" in properties: - spell_info = properties["spells"] - if spell_info.get("spells"): - item["spells"] = spell_info["spells"] - item["spell_names"] = [ - spell.get("name", "") - for spell in spell_info["spells"] - if spell.get("name") - ] - if spell_info.get("active_spells"): - item["active_spells"] = spell_info["active_spells"] - + if 'spells' in properties: + spell_info = properties['spells'] + if spell_info.get('spells'): + item['spells'] = spell_info['spells'] + item['spell_names'] = [spell.get('name', '') for spell in spell_info['spells'] if spell.get('name')] + if spell_info.get('active_spells'): + item['active_spells'] = spell_info['active_spells'] + # Add coverage calculation from coverage mask - int_values = original_json.get("IntValues", {}) + int_values = original_json.get('IntValues', {}) coverage_value = None - + # Check for coverage mask in correct location (218103821 = Coverage_Decal) - if "218103821" in int_values: - coverage_value = int_values["218103821"] + if '218103821' in int_values: + coverage_value = int_values['218103821'] elif 218103821 in int_values: coverage_value = int_values[218103821] - + if coverage_value and coverage_value > 0: coverage_parts = translate_coverage_mask(coverage_value) if coverage_parts: - item["coverage"] = ", ".join(coverage_parts) + item['coverage'] = ', '.join(coverage_parts) else: - item["coverage"] = f"Coverage_{coverage_value}" + item['coverage'] = f"Coverage_{coverage_value}" # Add raw coverage mask for armor reduction system - item["coverage_mask"] = coverage_value + item['coverage_mask'] = coverage_value else: - item["coverage"] = None - item["coverage_mask"] = 0 - + 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 - if original_json and "IntValues" in original_json: - equippable_slots = int_values.get( - "218103822", int_values.get(218103822, 0) - ) - coverage_value = int_values.get( - "218103821", int_values.get(218103821, 0) - ) - + if original_json and 'IntValues' in original_json: + equippable_slots = int_values.get('218103822', int_values.get(218103822, 0)) + coverage_value = int_values.get('218103821', int_values.get(218103821, 0)) + # Add debug info to help troubleshoot slot translation issues - if ( - "legging" in item["name"].lower() - or "greave" in item["name"].lower() - ): - item["debug_slot_info"] = { - "equippable_slots": equippable_slots, - "coverage_value": coverage_value, - "current_wielded_location": item.get( - "current_wielded_location", 0 - ), + if 'legging' in item['name'].lower() or 'greave' in item['name'].lower(): + item['debug_slot_info'] = { + 'equippable_slots': equippable_slots, + 'coverage_value': coverage_value, + 'current_wielded_location': item.get('current_wielded_location', 0) } - + if equippable_slots and int(equippable_slots) > 0: # Check if item has material (can be tailored) - has_material = bool( - item.get("material_name") - and item.get("material_name") != "" - ) - + has_material = bool(item.get('material_name') and item.get('material_name') != '') + # Get sophisticated slot options using Mag-SuitBuilder logic slot_options = get_sophisticated_slot_options( - int(equippable_slots), + int(equippable_slots), int(coverage_value) if coverage_value else 0, - has_material, + has_material ) - + # Translate all slot options to friendly names slot_names = [] for slot_option in slot_options: slot_name = translate_equipment_slot(slot_option) if slot_name and slot_name not in slot_names: slot_names.append(slot_name) - + # Debug logging for slot issues if not slot_names and equippable_slots in [30, 786432]: - logger.warning( - f"No slot names found for item '{item['name']}' with equippable_slots={equippable_slots}, slot_options={slot_options}" - ) - - item["slot_name"] = ( - ", ".join(slot_names) if slot_names else "-" - ) + logger.warning(f"No slot names found for item '{item['name']}' with equippable_slots={equippable_slots}, slot_options={slot_options}") + + item['slot_name'] = ', '.join(slot_names) if slot_names else "-" else: - item["slot_name"] = "-" + item['slot_name'] = "-" else: - item["slot_name"] = "-" - + item['slot_name'] = "-" + # Use gear totals as display ratings when individual ratings don't exist - # For armor/clothing, ratings are often stored as gear totals (370, 372, 374) - if item.get( - "damage_rating", -1 - ) == -1 and "gear_damage" in properties.get("ratings", {}): - gear_damage = properties["ratings"].get("gear_damage", -1) + # For armor/clothing, ratings are often stored as gear totals (370, 372, 374) + if item.get('damage_rating', -1) == -1 and 'gear_damage' in properties.get('ratings', {}): + gear_damage = properties['ratings'].get('gear_damage', -1) if gear_damage > 0: - item["damage_rating"] = gear_damage + item['damage_rating'] = gear_damage else: - item["damage_rating"] = None - elif item.get("damage_rating", -1) == -1: - item["damage_rating"] = None - - if item.get( - "crit_damage_rating", -1 - ) == -1 and "gear_crit_damage" in properties.get("ratings", {}): - gear_crit_damage = properties["ratings"].get( - "gear_crit_damage", -1 - ) + item['damage_rating'] = None + elif item.get('damage_rating', -1) == -1: + item['damage_rating'] = None + + if item.get('crit_damage_rating', -1) == -1 and 'gear_crit_damage' in properties.get('ratings', {}): + gear_crit_damage = properties['ratings'].get('gear_crit_damage', -1) if gear_crit_damage > 0: - item["crit_damage_rating"] = gear_crit_damage + item['crit_damage_rating'] = gear_crit_damage else: - item["crit_damage_rating"] = None - elif item.get("crit_damage_rating", -1) == -1: - item["crit_damage_rating"] = None - - if item.get( - "heal_boost_rating", -1 - ) == -1 and "gear_healing_boost" in properties.get("ratings", {}): - gear_healing_boost = properties["ratings"].get( - "gear_healing_boost", -1 - ) + item['crit_damage_rating'] = None + elif item.get('crit_damage_rating', -1) == -1: + item['crit_damage_rating'] = None + + if item.get('heal_boost_rating', -1) == -1 and 'gear_healing_boost' in properties.get('ratings', {}): + gear_healing_boost = properties['ratings'].get('gear_healing_boost', -1) if gear_healing_boost > 0: - item["heal_boost_rating"] = gear_healing_boost + item['heal_boost_rating'] = gear_healing_boost else: - item["heal_boost_rating"] = None - elif item.get("heal_boost_rating", -1) == -1: - item["heal_boost_rating"] = None - + item['heal_boost_rating'] = None + elif item.get('heal_boost_rating', -1) == -1: + item['heal_boost_rating'] = None + # Add equipment set name translation - if item.get("item_set") and str(item["item_set"]).strip(): - set_id = str(item["item_set"]).strip() + if item.get('item_set') and str(item['item_set']).strip(): + set_id = str(item['item_set']).strip() # Get dictionary from enum database - dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) - attribute_set_info = dictionaries.get( - "AttributeSetInfo", {} - ).get("values", {}) + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) if set_id in attribute_set_info: - item["item_set_name"] = attribute_set_info[set_id] + item['item_set_name'] = attribute_set_info[set_id] else: # Try checking if it's in the alternative location (equipment_sets) - equipment_sets = ENUM_MAPPINGS.get("equipment_sets", {}) + equipment_sets = ENUM_MAPPINGS.get('equipment_sets', {}) if set_id in equipment_sets: - item["item_set_name"] = equipment_sets[set_id] + item['item_set_name'] = equipment_sets[set_id] else: - item["item_set_name"] = f"Set {set_id}" - + item['item_set_name'] = f"Set {set_id}" + # Clean up - remove raw data from response - item.pop("original_json", None) - item.pop("db_item_id", None) - + item.pop('original_json', None) + item.pop('db_item_id', None) + items.append(item) - + return ItemSearchResponse( items=items, total_count=total_count, page=page, limit=limit, has_next=page * limit < total_count, - has_previous=page > 1, + has_previous=page > 1 ) - + except Exception as e: logger.error(f"Search error: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") @@ -4015,22 +3266,18 @@ async def search_items( @app.get("/search/equipped/{character_name}") async def search_equipped_items( character_name: str, - slot: int = Query(None, description="Specific equipment slot mask"), + slot: int = Query(None, description="Specific equipment slot mask") ): """Get all equipped items for a character, optionally filtered by slot.""" try: - conditions = [ - "i.character_name = :character_name", - "i.current_wielded_location > 0", - ] + conditions = ["i.character_name = :character_name", "i.current_wielded_location > 0"] params = {"character_name": character_name} - + if slot is not None: conditions.append("i.current_wielded_location = :slot") params["slot"] = slot - - query = ( - """ + + query = """ SELECT i.*, COALESCE(cs.max_damage, -1) as max_damage, @@ -4044,55 +3291,45 @@ async def search_equipped_items( LEFT JOIN item_combat_stats cs ON i.id = cs.item_id LEFT JOIN item_requirements req ON i.id = req.item_id LEFT JOIN item_enhancements enh ON i.id = enh.item_id - WHERE """ - + " AND ".join(conditions) - + """ + WHERE """ + " AND ".join(conditions) + """ ORDER BY i.current_wielded_location, i.name """ - ) - + rows = await database.fetch_all(query, params) - + # Load EquipMask enum for slot names equip_mask_map = {} - if "EquipMask" in ENUM_MAPPINGS.get("full_database", {}).get("enums", {}): - equip_data = ENUM_MAPPINGS["full_database"]["enums"]["EquipMask"]["values"] + if 'EquipMask' in ENUM_MAPPINGS.get('full_database', {}).get('enums', {}): + equip_data = ENUM_MAPPINGS['full_database']['enums']['EquipMask']['values'] for k, v in equip_data.items(): try: equip_mask_map[int(k)] = v except (ValueError, TypeError): pass - + items = [] for row in rows: item = dict(row) - item["slot_name"] = equip_mask_map.get( - item["current_wielded_location"], - f"Slot_{item['current_wielded_location']}", - ) + item['slot_name'] = equip_mask_map.get(item['current_wielded_location'], f"Slot_{item['current_wielded_location']}") items.append(item) - + return { "character_name": character_name, "equipped_items": items, "slot_filter": slot, - "item_count": len(items), + "item_count": len(items) } - + except Exception as e: logger.error(f"Equipped items search error: {e}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Equipped items search failed: {str(e)}" - ) + raise HTTPException(status_code=500, detail=f"Equipped items search failed: {str(e)}") @app.get("/search/upgrades/{character_name}/{slot}") async def find_equipment_upgrades( character_name: str, slot: int, - upgrade_type: str = Query( - "damage", description="What to optimize for: damage, armor, workmanship, value" - ), + upgrade_type: str = Query("damage", description="What to optimize for: damage, armor, workmanship, value") ): """Find potential equipment upgrades for a specific slot.""" try: @@ -4105,11 +3342,12 @@ async def find_equipment_upgrades( WHERE i.character_name = :character_name AND i.current_wielded_location = :slot """ - - current_item = await database.fetch_one( - current_query, {"character_name": character_name, "slot": slot} - ) - + + current_item = await database.fetch_one(current_query, { + "character_name": character_name, + "slot": slot + }) + # Find all unequipped items that could be equipped in this slot # Check ValidLocations or infer from similar equipped items upgrade_query = """ @@ -4129,11 +3367,11 @@ async def find_equipment_upgrades( WHERE i.current_wielded_location = 0 AND i.object_class = :object_class """ - + params = {} if current_item: params["object_class"] = current_item["object_class"] - + # Add upgrade criteria based on current item if upgrade_type == "damage" and current_item.get("max_damage", -1) > 0: upgrade_query += " AND cs.max_damage > :current_damage" @@ -4141,10 +3379,7 @@ async def find_equipment_upgrades( elif upgrade_type == "armor" and current_item.get("armor_level", -1) > 0: upgrade_query += " AND cs.armor_level > :current_armor" params["current_armor"] = current_item["armor_level"] - elif ( - upgrade_type == "workmanship" - and current_item.get("workmanship", -1) > 0 - ): + elif upgrade_type == "workmanship" and current_item.get("workmanship", -1) > 0: upgrade_query += " AND enh.workmanship > :current_workmanship" params["current_workmanship"] = current_item["workmanship"] elif upgrade_type == "value": @@ -4154,43 +3389,39 @@ async def find_equipment_upgrades( # No current item, show all available items for this slot type # We'll need to infer object class from slot - this is a simplified approach params["object_class"] = 1 # Default to generic - + # Add sorting based on upgrade type if upgrade_type == "damage": upgrade_query += " ORDER BY cs.max_damage DESC" elif upgrade_type == "armor": - upgrade_query += " ORDER BY cs.armor_level DESC" + upgrade_query += " ORDER BY cs.armor_level DESC" elif upgrade_type == "workmanship": upgrade_query += " ORDER BY enh.workmanship DESC" else: upgrade_query += " ORDER BY i.value DESC" - + upgrade_query += " LIMIT 20" # Limit to top 20 upgrades - + upgrades = await database.fetch_all(upgrade_query, params) - + return { "character_name": character_name, "slot": slot, "upgrade_type": upgrade_type, "current_item": dict(current_item) if current_item else None, "potential_upgrades": [dict(row) for row in upgrades], - "upgrade_count": len(upgrades), + "upgrade_count": len(upgrades) } - + except Exception as e: logger.error(f"Equipment upgrades search error: {e}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Equipment upgrades search failed: {str(e)}" - ) + raise HTTPException(status_code=500, detail=f"Equipment upgrades search failed: {str(e)}") -@app.get( - "/characters/list", - summary="List Characters", - description="Get a list of all characters that have inventory data in the database.", - tags=["Character Data"], -) +@app.get("/characters/list", + 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: @@ -4201,72 +3432,69 @@ async def list_inventory_characters(): ORDER BY character_name """ rows = await database.fetch_all(query) - + characters = [] for row in rows: - characters.append( - { - "character_name": row["character_name"], - "item_count": row["item_count"], - "last_updated": row["last_updated"], - } - ) - - return {"characters": characters, "total_characters": len(characters)} - + characters.append({ + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"] + }) + + return { + "characters": characters, + "total_characters": len(characters) + } + except Exception as e: logger.error(f"Failed to list inventory characters: {e}") - raise HTTPException( - status_code=500, detail="Failed to list inventory characters" - ) + raise HTTPException(status_code=500, detail="Failed to list inventory characters") @app.get("/search/by-slot") async def search_items_by_slot( - slot: str = Query( - ..., description="Slot to search for (e.g., 'Head', 'Chest', 'Hands')" - ), - characters: str = Query( - None, description="Comma-separated list of character names" - ), + slot: str = Query(..., description="Slot to search for (e.g., 'Head', 'Chest', 'Hands')"), + characters: str = Query(None, description="Comma-separated list of character names"), include_all_characters: bool = Query(False, description="Include all characters"), + # Equipment type armor_only: bool = Query(True, description="Show only armor items"), + # Pagination limit: int = Query(100, le=1000), - offset: int = Query(0, ge=0), + offset: int = Query(0, ge=0) ): """Search for items that can be equipped in a specific slot.""" try: # Build query conditions = [] params = {} - + # TODO: Implement slot filtering once we have slot_name in DB # For now, return message about missing implementation if slot: return { "error": "Slot-based search not yet implemented", "message": f"Cannot search for slot '{slot}' - slot_name field needs to be added to database", - "suggestion": "Use regular /search/items endpoint with filters for now", + "suggestion": "Use regular /search/items endpoint with filters for now" } - + # Character filtering if not include_all_characters and characters: - char_list = [c.strip() for c in characters.split(",") if c.strip()] + char_list = [c.strip() for c in characters.split(',') if c.strip()] if char_list: placeholders = [f":char_{i}" for i in range(len(char_list))] conditions.append(f"character_name IN ({', '.join(placeholders)})") for i, char in enumerate(char_list): params[f"char_{i}"] = char - + # Armor only filter if armor_only: conditions.append("(object_class IN (2, 3))") - + # Build final query where_clause = " AND ".join(conditions) if conditions else "1=1" - + query = f""" SELECT i.id, i.character_name, i.name, i.icon, i.object_class, i.current_wielded_location, @@ -4276,36 +3504,32 @@ async def search_items_by_slot( ORDER BY i.character_name, i.name LIMIT :limit OFFSET :offset """ - - params["limit"] = limit - params["offset"] = offset - + + params['limit'] = limit + params['offset'] = offset + # Execute query rows = await database.fetch_all(query, params) - + # Count total count_query = f"SELECT COUNT(*) as total FROM items i WHERE {where_clause}" - count_params = { - k: v - for k, v in params.items() - if k not in ["limit", "offset", "slot_requested"] - } + count_params = {k: v for k, v in params.items() if k not in ['limit', 'offset', 'slot_requested']} count_result = await database.fetch_one(count_query, count_params) total = count_result[0] if count_result else 0 - + # Process results items = [] for row in rows: items.append(dict(row)) - + return { "items": items, "total_count": total or 0, "slot_searched": slot, "limit": limit, - "offset": offset, + "offset": offset } - + except Exception as e: logger.error(f"Error in slot search: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -4313,38 +3537,31 @@ async def search_items_by_slot( @app.get("/analyze/sets") async def analyze_set_combinations( - characters: str = Query( - None, description="Comma-separated list of character names" - ), + characters: str = Query(None, description="Comma-separated list of character names"), include_all_characters: bool = Query(False, description="Include all characters"), primary_set: int = Query(..., description="Primary set ID (needs 5 pieces)"), secondary_set: int = Query(..., description="Secondary set ID (needs 4 pieces)"), primary_count: int = Query(5, description="Number of primary set pieces needed"), - secondary_count: int = Query( - 4, description="Number of secondary set pieces needed" - ), + secondary_count: int = Query(4, description="Number of secondary set pieces needed") ): """Analyze set combinations for valid 5+4 equipment builds.""" try: # Simplified approach - just count items by set for each character # This uses the same pattern as existing working queries - + if include_all_characters: # Query all characters char_filter = "1=1" query_params = [] elif characters: # Query specific characters - character_list = [c.strip() for c in characters.split(",") if c.strip()] - char_placeholders = ",".join(["%s"] * len(character_list)) + character_list = [c.strip() for c in characters.split(',') if c.strip()] + char_placeholders = ','.join(['%s'] * len(character_list)) char_filter = f"i.character_name IN ({char_placeholders})" query_params = character_list else: - raise HTTPException( - status_code=400, - detail="Must specify characters or include_all_characters", - ) - + raise HTTPException(status_code=400, detail="Must specify characters or include_all_characters") + # Query for primary set primary_query = f""" SELECT @@ -4358,7 +3575,7 @@ async def analyze_set_combinations( AND ird.int_values ? '265' AND (ird.int_values->>'265')::int = :primary_set_id """ - + # Query for secondary set secondary_query = f""" SELECT @@ -4372,7 +3589,7 @@ async def analyze_set_combinations( AND ird.int_values ? '265' AND (ird.int_values->>'265')::int = :secondary_set_id """ - + # Build parameter dictionaries if include_all_characters: primary_params = {"primary_set_id": primary_set} @@ -4380,108 +3597,90 @@ async def analyze_set_combinations( else: # For character filtering, we need to embed character names directly in query # because using named parameters with IN clauses is complex - character_list = [c.strip() for c in characters.split(",") if c.strip()] + character_list = [c.strip() for c in characters.split(',') if c.strip()] char_names = "', '".join(character_list) - - primary_query = primary_query.replace( - char_filter, f"i.character_name IN ('{char_names}')" - ) - secondary_query = secondary_query.replace( - char_filter, f"i.character_name IN ('{char_names}')" - ) - + + primary_query = primary_query.replace(char_filter, f"i.character_name IN ('{char_names}')") + secondary_query = secondary_query.replace(char_filter, f"i.character_name IN ('{char_names}')") + primary_params = {"primary_set_id": primary_set} secondary_params = {"secondary_set_id": secondary_set} - + # Execute queries primary_result = await database.fetch_all(primary_query, primary_params) secondary_result = await database.fetch_all(secondary_query, secondary_params) - + # Process results by character primary_by_char = {} secondary_by_char = {} - + for row in primary_result: - char = row["character_name"] + char = row['character_name'] if char not in primary_by_char: primary_by_char[char] = [] - primary_by_char[char].append( - {"name": row["name"], "equipped": row["current_wielded_location"] > 0} - ) - + primary_by_char[char].append({ + 'name': row['name'], + 'equipped': row['current_wielded_location'] > 0 + }) + for row in secondary_result: - char = row["character_name"] + char = row['character_name'] if char not in secondary_by_char: secondary_by_char[char] = [] - secondary_by_char[char].append( - {"name": row["name"], "equipped": row["current_wielded_location"] > 0} - ) - + secondary_by_char[char].append({ + 'name': row['name'], + 'equipped': row['current_wielded_location'] > 0 + }) + # Analyze combinations analysis_results = [] all_characters = set(primary_by_char.keys()) | set(secondary_by_char.keys()) - + for char in all_characters: primary_items = primary_by_char.get(char, []) secondary_items = secondary_by_char.get(char, []) - + primary_available = len(primary_items) secondary_available = len(secondary_items) - - can_build = ( - primary_available >= primary_count - and secondary_available >= secondary_count - ) - - analysis_results.append( - { - "character_name": char, - "primary_set_available": primary_available, - "primary_set_needed": primary_count, - "secondary_set_available": secondary_available, - "secondary_set_needed": secondary_count, - "can_build_combination": can_build, - "primary_items": primary_items[:primary_count] - if can_build - else primary_items, - "secondary_items": secondary_items[:secondary_count] - if can_build - else secondary_items, - } - ) - + + can_build = (primary_available >= primary_count and + secondary_available >= secondary_count) + + analysis_results.append({ + 'character_name': char, + 'primary_set_available': primary_available, + 'primary_set_needed': primary_count, + 'secondary_set_available': secondary_available, + 'secondary_set_needed': secondary_count, + 'can_build_combination': can_build, + 'primary_items': primary_items[:primary_count] if can_build else primary_items, + 'secondary_items': secondary_items[:secondary_count] if can_build else secondary_items + }) + # Sort by characters who can build first - analysis_results.sort( - key=lambda x: (not x["can_build_combination"], x["character_name"]) - ) - + analysis_results.sort(key=lambda x: (not x['can_build_combination'], x['character_name'])) + # Get set names for response - attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get("values", {}) - primary_set_name = attribute_set_info.get( - str(primary_set), f"Set {primary_set}" - ) - secondary_set_name = attribute_set_info.get( - str(secondary_set), f"Set {secondary_set}" - ) - + attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) + primary_set_name = attribute_set_info.get(str(primary_set), f"Set {primary_set}") + secondary_set_name = attribute_set_info.get(str(secondary_set), f"Set {secondary_set}") + return { - "primary_set": { - "id": primary_set, - "name": primary_set_name, - "pieces_needed": primary_count, + 'primary_set': { + 'id': primary_set, + 'name': primary_set_name, + 'pieces_needed': primary_count }, - "secondary_set": { - "id": secondary_set, - "name": secondary_set_name, - "pieces_needed": secondary_count, + 'secondary_set': { + 'id': secondary_set, + 'name': secondary_set_name, + 'pieces_needed': secondary_count }, - "character_analysis": analysis_results, - "total_characters": len(analysis_results), - "characters_can_build": len( - [r for r in analysis_results if r["can_build_combination"]] - ), + 'character_analysis': analysis_results, + 'total_characters': len(analysis_results), + 'characters_can_build': len([r for r in analysis_results if r['can_build_combination']]) } - + except Exception as e: logger.error(f"Error in set combination analysis: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -4489,19 +3688,13 @@ async def analyze_set_combinations( @app.get("/slots/available") async def get_available_items_by_slot( - characters: str = Query( - None, description="Comma-separated list of character names" - ), + characters: str = Query(None, description="Comma-separated list of character names"), include_all_characters: bool = Query(False, description="Include all characters"), - equipment_sets: str = Query( - None, description="Comma-separated equipment set IDs to filter by" - ), - legendary_cantrips: str = Query( - None, description="Comma-separated legendary cantrips to filter by" - ), + equipment_sets: str = Query(None, description="Comma-separated equipment set IDs to filter by"), + legendary_cantrips: str = Query(None, description="Comma-separated legendary cantrips to filter by"), min_crit_damage_rating: int = Query(None, description="Minimum crit damage rating"), min_damage_rating: int = Query(None, description="Minimum damage rating"), - min_armor_level: int = Query(None, description="Minimum armor level"), + min_armor_level: int = Query(None, description="Minimum armor level") ): """Get available items organized by equipment slot across multiple characters.""" try: @@ -4510,66 +3703,47 @@ async def get_available_items_by_slot( char_filter = "1=1" query_params = {} elif characters: - character_list = [c.strip() for c in characters.split(",") if c.strip()] + character_list = [c.strip() for c in characters.split(',') if c.strip()] char_names = "', '".join(character_list) char_filter = f"i.character_name IN ('{char_names}')" query_params = {} else: - raise HTTPException( - status_code=400, - detail="Must specify characters or include_all_characters", - ) - + raise HTTPException(status_code=400, detail="Must specify characters or include_all_characters") + # Build constraints - only filter if we have specific constraints constraints = [] - if ( - equipment_sets - or legendary_cantrips - or min_crit_damage_rating - or min_damage_rating - or min_armor_level - ): - # Only filter by object class if we have other filters + if equipment_sets or legendary_cantrips or min_crit_damage_rating or min_damage_rating or min_armor_level: + # Only filter by object class if we have other filters constraints.append("i.object_class IN (2, 3, 4)") # Armor and jewelry else: # For general slot queries, include more object classes but focus on equipment - constraints.append( - "i.object_class IN (1, 2, 3, 4, 6, 7, 8)" - ) # All equipment types - + constraints.append("i.object_class IN (1, 2, 3, 4, 6, 7, 8)") # All equipment types + # Equipment set filtering if equipment_sets: - set_ids = [s.strip() for s in equipment_sets.split(",") if s.strip()] - set_filter = " OR ".join( - [f"(ird.int_values->>'265')::int = {set_id}" for set_id in set_ids] - ) + set_ids = [s.strip() for s in equipment_sets.split(',') if s.strip()] + set_filter = " OR ".join([f"(ird.int_values->>'265')::int = {set_id}" for set_id in set_ids]) constraints.append(f"ird.int_values ? '265' AND ({set_filter})") - + # Rating filters using gear totals if min_crit_damage_rating: - constraints.append( - f"COALESCE((ird.int_values->>'370')::int, 0) >= {min_crit_damage_rating}" - ) + constraints.append(f"COALESCE((ird.int_values->>'370')::int, 0) >= {min_crit_damage_rating}") if min_damage_rating: - constraints.append( - f"COALESCE((ird.int_values->>'372')::int, 0) >= {min_damage_rating}" - ) + constraints.append(f"COALESCE((ird.int_values->>'372')::int, 0) >= {min_damage_rating}") if min_armor_level: - constraints.append( - f"COALESCE((ird.int_values->>'28')::int, 0) >= {min_armor_level}" - ) - + constraints.append(f"COALESCE((ird.int_values->>'28')::int, 0) >= {min_armor_level}") + # Build WHERE clause properly where_parts = [char_filter] if constraints: where_parts.extend(constraints) where_clause = " AND ".join(where_parts) - + # Debug: let's see how many items Barris actually has first debug_query = f"SELECT COUNT(*) as total FROM items WHERE {char_filter}" debug_result = await database.fetch_one(debug_query, query_params) logger.debug(f"Total items for query: {debug_result['total']}") - + # Main query to get items with slot information query = f""" SELECT DISTINCT @@ -4612,154 +3786,137 @@ async def get_available_items_by_slot( WHERE {where_clause} ORDER BY i.character_name, i.name """ - + # Cantrip filtering if requested if legendary_cantrips: - cantrip_list = [ - c.strip() for c in legendary_cantrips.split(",") if c.strip() - ] + cantrip_list = [c.strip() for c in legendary_cantrips.split(',') if c.strip()] # Get spell IDs for the requested cantrips cantrip_spell_ids = [] - spells_data = ENUM_MAPPINGS.get("spells", {}).get("values", {}) + spells_data = ENUM_MAPPINGS.get('spells', {}).get('values', {}) for cantrip in cantrip_list: for spell_id, spell_name in spells_data.items(): if cantrip.lower() in spell_name.lower(): cantrip_spell_ids.append(int(spell_id)) - + if cantrip_spell_ids: # Add JOIN to filter by spells - spell_placeholders = ",".join(map(str, cantrip_spell_ids)) - query = query.replace( - "FROM items i", - f""" + spell_placeholders = ','.join(map(str, cantrip_spell_ids)) + query = query.replace("FROM items i", f""" FROM items i INNER JOIN item_spells isp ON i.id = isp.item_id AND isp.spell_id IN ({spell_placeholders}) - """, - ) - + """) + # Execute query rows = await database.fetch_all(query, query_params) - + # Organize items by slot slots_data = {} - + # Define the 9 armor slots we care about armor_slots = { 1: "Head", - 512: "Chest", + 512: "Chest", 2048: "Upper Arms", - 4096: "Lower Arms", + 4096: "Lower Arms", 32: "Hands", 1024: "Abdomen", 8192: "Upper Legs", - 16384: "Lower Legs", - 256: "Feet", + 16384: "Lower Legs", + 256: "Feet" } - + # Jewelry slots jewelry_slots = { "Neck": "Neck", "Left Ring": "Left Ring", - "Right Ring": "Right Ring", + "Right Ring": "Right Ring", "Left Wrist": "Left Wrist", - "Right Wrist": "Right Wrist", + "Right Wrist": "Right Wrist" } - + # Initialize all slots for slot_name in armor_slots.values(): slots_data[slot_name] = [] for slot_name in jewelry_slots.values(): slots_data[slot_name] = [] - + # Process each item for row in rows: item_data = { - "id": row["id"], - "character_name": row["character_name"], - "name": row["name"], - "is_equipped": row["current_wielded_location"] > 0, - "armor_level": row["armor_level"], - "crit_damage_rating": row["crit_damage_rating"], - "damage_rating": row["damage_rating"], - "item_set_id": row["item_set_id"], + "id": row['id'], + "character_name": row['character_name'], + "name": row['name'], + "is_equipped": row['current_wielded_location'] > 0, + "armor_level": row['armor_level'], + "crit_damage_rating": row['crit_damage_rating'], + "damage_rating": row['damage_rating'], + "item_set_id": row['item_set_id'] } - + # Add material name if available - if row["material_type"]: - material_name = ENUM_MAPPINGS.get("materials", {}).get( - str(row["material_type"]), "" - ) - if material_name and not row["name"].lower().startswith( - material_name.lower() - ): - item_data["name"] = f"{material_name} {row['name']}" - + if row['material_type']: + material_name = ENUM_MAPPINGS.get('materials', {}).get(str(row['material_type']), '') + if material_name and not row['name'].lower().startswith(material_name.lower()): + item_data['name'] = f"{material_name} {row['name']}" + # Add set name if available - if row["item_set_id"]: - attribute_set_info = ENUM_MAPPINGS.get("AttributeSetInfo", {}).get( - "values", {} - ) - set_name = attribute_set_info.get(str(row["item_set_id"]), "") + if row['item_set_id']: + attribute_set_info = ENUM_MAPPINGS.get('AttributeSetInfo', {}).get('values', {}) + set_name = attribute_set_info.get(str(row['item_set_id']), '') if set_name: - item_data["set_name"] = set_name - + item_data['set_name'] = set_name + # Use the same slot computation logic as the search endpoint - equippable_slots = row["valid_locations"] - coverage_value = row["coverage_mask"] + equippable_slots = row['valid_locations'] + coverage_value = row['coverage_mask'] slot_names = [] - + if equippable_slots and equippable_slots > 0: # Get sophisticated slot options (handles armor reduction) - has_material = row["material_type"] is not None - slot_options = get_sophisticated_slot_options( - equippable_slots, coverage_value, has_material - ) - + has_material = row['material_type'] is not None + slot_options = get_sophisticated_slot_options(equippable_slots, coverage_value, has_material) + # Convert slot options to friendly slot names for slot_option in slot_options: slot_name = translate_equipment_slot(slot_option) if slot_name and slot_name not in slot_names: slot_names.append(slot_name) - + # Add item to each computed slot for slot_name in slot_names: if slot_name in slots_data: slots_data[slot_name].append(item_data.copy()) - + # Handle jewelry separately if armor logic didn't work - if row["object_class"] == 4 and not slot_names: # Jewelry - item_name = row["name"].lower() - - if "ring" in item_name: + if row['object_class'] == 4 and not slot_names: # Jewelry + item_name = row['name'].lower() + + if 'ring' in item_name: slots_data["Left Ring"].append(item_data.copy()) slots_data["Right Ring"].append(item_data.copy()) - elif any(word in item_name for word in ["bracelet", "wrist"]): + elif any(word in item_name for word in ['bracelet', 'wrist']): slots_data["Left Wrist"].append(item_data.copy()) slots_data["Right Wrist"].append(item_data.copy()) - elif any( - word in item_name for word in ["necklace", "amulet", "gorget"] - ): + elif any(word in item_name for word in ['necklace', 'amulet', 'gorget']): slots_data["Neck"].append(item_data.copy()) - + # Sort items within each slot by character name, then by name for slot in slots_data: - slots_data[slot].sort(key=lambda x: (x["character_name"], x["name"])) - + slots_data[slot].sort(key=lambda x: (x['character_name'], x['name'])) + return { "slots": slots_data, "total_items": sum(len(items) for items in slots_data.values()), "constraints_applied": { - "equipment_sets": equipment_sets.split(",") if equipment_sets else None, - "legendary_cantrips": legendary_cantrips.split(",") - if legendary_cantrips - else None, + "equipment_sets": equipment_sets.split(',') if equipment_sets else None, + "legendary_cantrips": legendary_cantrips.split(',') if legendary_cantrips else None, "min_crit_damage_rating": min_crit_damage_rating, "min_damage_rating": min_damage_rating, - "min_armor_level": min_armor_level, - }, + "min_armor_level": min_armor_level + } } - + except Exception as e: logger.error(f"Error in slots/available endpoint: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -4768,26 +3925,17 @@ async def get_available_items_by_slot( @app.post("/optimize/suits") async def optimize_suits( # Character selection - characters: str = Query( - None, description="Comma-separated list of character names" - ), - include_all_characters: bool = Query( - False, description="Search across all characters" - ), + characters: str = Query(None, description="Comma-separated list of character names"), + include_all_characters: bool = Query(False, description="Search across all characters"), + # Equipment sets (primary/secondary requirements) - primary_set: str = Query( - None, description="Primary equipment set ID (requires 5 pieces)" - ), - secondary_set: str = Query( - None, description="Secondary equipment set ID (requires 4 pieces)" - ), + primary_set: str = Query(None, description="Primary equipment set ID (requires 5 pieces)"), + secondary_set: str = Query(None, description="Secondary equipment set ID (requires 4 pieces)"), + # Spell requirements - legendary_cantrips: str = Query( - None, description="Comma-separated list of required legendary cantrips" - ), - legendary_wards: str = Query( - None, description="Comma-separated list of required legendary wards" - ), + legendary_cantrips: str = Query(None, description="Comma-separated list of required legendary cantrips"), + legendary_wards: str = Query(None, description="Comma-separated list of required legendary wards"), + # Rating requirements min_armor: int = Query(None, description="Minimum total armor level"), max_armor: int = Query(None, description="Maximum total armor level"), @@ -4795,21 +3943,20 @@ async def optimize_suits( max_crit_damage: int = Query(None, description="Maximum total crit damage rating"), min_damage_rating: int = Query(None, description="Minimum total damage rating"), max_damage_rating: int = Query(None, description="Maximum total damage rating"), + # Equipment status include_equipped: bool = Query(True, description="Include equipped items"), include_inventory: bool = Query(True, description="Include inventory items"), + # Locked slots (exclude from optimization) - locked_slots: str = Query( - None, description="Comma-separated list of locked slot names" - ), + locked_slots: str = Query(None, description="Comma-separated list of locked slot names"), + # Result options - max_results: int = Query( - 10, ge=1, le=50, description="Maximum number of suit results to return" - ), + max_results: int = Query(10, ge=1, le=50, description="Maximum number of suit results to return") ): """ MagSuitbuilder-inspired constraint solver for optimal equipment combinations. - + Uses two-phase algorithm: ArmorSearcher with strict set filtering, then AccessorySearcher for spells. """ try: @@ -4820,13 +3967,17 @@ async def optimize_suits( query = "SELECT DISTINCT character_name FROM items ORDER BY character_name" async with database.transaction(): rows = await database.fetch_all(query) - char_list = [row["character_name"] for row in rows] + char_list = [row['character_name'] for row in rows] elif characters: - char_list = [c.strip() for c in characters.split(",") if c.strip()] - + char_list = [c.strip() for c in characters.split(',') if c.strip()] + if not char_list: - return {"suits": [], "message": "No characters specified", "total_found": 0} - + return { + "suits": [], + "message": "No characters specified", + "total_found": 0 + } + # Determine equipment status filtering logic equipment_status_filter = None if include_equipped and include_inventory: @@ -4839,36 +3990,28 @@ async def optimize_suits( return { "suits": [], "message": "Must include either equipped or inventory items", - "total_found": 0, + "total_found": 0 } - + # Build constraints dictionary constraints = { - "primary_set": int(primary_set) if primary_set else None, - "secondary_set": int(secondary_set) if secondary_set else None, - "min_armor": min_armor or 0, - "min_crit_damage": min_crit_damage or 0, - "min_damage_rating": min_damage_rating or 0, - "min_heal_boost": 0, - "legendary_cantrips": [ - c.strip() for c in legendary_cantrips.split(",") if c.strip() - ] - if legendary_cantrips - else [], - "protection_spells": [ - w.strip() for w in legendary_wards.split(",") if w.strip() - ] - if legendary_wards - else [], - "equipment_status_filter": equipment_status_filter, + 'primary_set': int(primary_set) if primary_set else None, + 'secondary_set': int(secondary_set) if secondary_set else None, + 'min_armor': min_armor or 0, + 'min_crit_damage': min_crit_damage or 0, + 'min_damage_rating': min_damage_rating or 0, + 'min_heal_boost': 0, + 'legendary_cantrips': [c.strip() for c in legendary_cantrips.split(',') if c.strip()] if legendary_cantrips else [], + 'protection_spells': [w.strip() for w in legendary_wards.split(',') if w.strip()] if legendary_wards else [], + 'equipment_status_filter': equipment_status_filter } - + # Initialize MagSuitbuilder-inspired solver solver = ConstraintSatisfactionSolver(char_list, constraints) - + # Execute two-phase search algorithm results = await solver.find_optimal_suits() - + return { "suits": results["suits"], "total_found": results["total_found"], @@ -4876,21 +4019,19 @@ async def optimize_suits( "accessory_items_available": results.get("accessory_items_available", 0), "message": results.get("message", "Search completed successfully"), "constraints": constraints, - "characters_searched": char_list, + "characters_searched": char_list } - + except Exception as e: logger.error(f"Error in optimize/suits endpoint: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/debug/available-sets") -async def get_available_sets( - characters: str = Query(..., description="Comma-separated character names"), -): +async def get_available_sets(characters: str = Query(..., description="Comma-separated character names")): """Debug endpoint to see what equipment sets are available""" - character_list = [c.strip() for c in characters.split(",") if c.strip()] - + character_list = [c.strip() for c in characters.split(',') if c.strip()] + query = """ SELECT DISTINCT enh.item_set, COUNT(*) as item_count FROM items i @@ -4902,90 +4043,69 @@ async def get_available_sets( GROUP BY enh.item_set ORDER BY item_count DESC """ - + async with database.transaction(): rows = await database.fetch_all(query, {"characters": character_list}) - - return { - "available_sets": [ - {"set_id": row["item_set"], "armor_count": row["item_count"]} - for row in rows - ] - } - + + return {"available_sets": [{"set_id": row["item_set"], "armor_count": row["item_count"]} for row in rows]} @app.get("/debug/test-simple-search") -async def test_simple_search( - characters: str = Query(..., description="Comma-separated character names"), -): +async def test_simple_search(characters: str = Query(..., description="Comma-separated character names")): """Test endpoint to find suits with NO constraints""" - character_list = [c.strip() for c in characters.split(",") if c.strip()] - + character_list = [c.strip() for c in characters.split(',') if c.strip()] + # Create minimal constraints (no set requirements, no cantrip requirements) constraints = { - "primary_set": None, - "secondary_set": None, - "min_armor": 0, - "min_crit_damage": 0, - "min_damage_rating": 0, - "min_heal_boost": 0, - "legendary_cantrips": [], - "protection_spells": [], - "equipment_status_filter": "both", + 'primary_set': None, + 'secondary_set': None, + 'min_armor': 0, + 'min_crit_damage': 0, + 'min_damage_rating': 0, + 'min_heal_boost': 0, + 'legendary_cantrips': [], + 'protection_spells': [], + 'equipment_status_filter': 'both' } - + try: solver = ConstraintSatisfactionSolver(character_list, constraints) result = await solver.find_optimal_suits() - + return { "message": "Simple search (no constraints) completed", "suits_found": result.get("total_found", 0), "armor_items_available": result.get("armor_items_available", 0), "accessory_items_available": result.get("accessory_items_available", 0), - "sample_suits": result.get("suits", [])[:3], # First 3 suits + "sample_suits": result.get("suits", [])[:3] # First 3 suits } except Exception as e: logger.error(f"Error in simple search: {e}") return {"error": str(e)} - @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" - ), + secondary_set: Optional[int] = Query(None, description="Secondary set ID requirement"), min_armor: Optional[int] = Query(None, description="Minimum armor requirement"), - min_crit_damage: Optional[int] = Query( - None, description="Minimum crit damage requirement" - ), - min_damage_rating: Optional[int] = Query( - None, description="Minimum damage rating requirement" - ), - legendary_cantrips: Optional[str] = Query( - None, description="Comma-separated legendary cantrips" - ), - legendary_wards: Optional[str] = Query( - None, description="Comma-separated protection spells" - ), + min_crit_damage: Optional[int] = Query(None, description="Minimum crit damage requirement"), + min_damage_rating: Optional[int] = Query(None, description="Minimum damage rating requirement"), + legendary_cantrips: Optional[str] = Query(None, description="Comma-separated legendary cantrips"), + legendary_wards: Optional[str] = Query(None, description="Comma-separated protection spells"), include_equipped: bool = Query(True, description="Include equipped items"), include_inventory: bool = Query(True, description="Include inventory items"), - search_depth: str = Query( - "balanced", description="Search depth: quick, balanced, deep, exhaustive" - ), + search_depth: str = Query("balanced", description="Search depth: quick, balanced, deep, exhaustive") ): """Stream suit optimization results progressively using Server-Sent Events""" - + # Validate input if not characters: raise HTTPException(status_code=400, detail="No characters specified") - + # Split character names - character_list = [c.strip() for c in characters.split(",") if c.strip()] - + character_list = [c.strip() for c in characters.split(',') if c.strip()] + # Determine equipment status filter if include_equipped and include_inventory: equipment_status_filter = "both" @@ -4994,50 +4114,40 @@ async def stream_optimize_suits( elif include_inventory: equipment_status_filter = "inventory_only" else: - raise HTTPException( - status_code=400, detail="Must include either equipped or inventory items" - ) - + raise HTTPException(status_code=400, detail="Must include either equipped or inventory items") + # Build constraints constraints = { - "primary_set": primary_set, - "secondary_set": secondary_set, - "min_armor": min_armor or 0, - "min_crit_damage": min_crit_damage or 0, - "min_damage_rating": min_damage_rating or 0, - "min_heal_boost": 0, - "legendary_cantrips": [ - c.strip() for c in legendary_cantrips.split(",") if c.strip() - ] - if legendary_cantrips - else [], - "protection_spells": [ - w.strip() for w in legendary_wards.split(",") if w.strip() - ] - if legendary_wards - else [], - "equipment_status_filter": equipment_status_filter, + 'primary_set': primary_set, + 'secondary_set': secondary_set, + 'min_armor': min_armor or 0, + 'min_crit_damage': min_crit_damage or 0, + 'min_damage_rating': min_damage_rating or 0, + 'min_heal_boost': 0, + 'legendary_cantrips': [c.strip() for c in legendary_cantrips.split(',') if c.strip()] if legendary_cantrips else [], + 'protection_spells': [w.strip() for w in legendary_wards.split(',') if w.strip()] if legendary_wards else [], + 'equipment_status_filter': equipment_status_filter } - + async def generate_suits(): """Generator that yields suits progressively""" solver = ConstraintSatisfactionSolver(character_list, constraints) - + # Configure search depth search_limits = { "quick": {"max_combinations": 10, "time_limit": 2}, "balanced": {"max_combinations": 50, "time_limit": 10}, "deep": {"max_combinations": 200, "time_limit": 30}, - "exhaustive": {"max_combinations": 1000, "time_limit": 120}, + "exhaustive": {"max_combinations": 1000, "time_limit": 120} } - + limit_config = search_limits.get(search_depth, search_limits["balanced"]) solver.max_combinations = limit_config["max_combinations"] - + # Start search start_time = time.time() found_count = 0 - + try: # Phase 1: Get armor items logger.info(f"Starting search with constraints: {constraints}") @@ -5047,84 +4157,71 @@ async def stream_optimize_suits( logger.warning("No armor items found matching set requirements") yield { "event": "error", - "data": json.dumps( - {"message": "No armor items found matching set requirements"} - ), + "data": json.dumps({"message": "No armor items found matching set requirements"}) } return - + # Phase 2: Get accessories accessory_items = await solver._get_accessory_items() logger.info(f"Found {len(accessory_items)} accessory items") - + # Yield initial status yield { "event": "status", - "data": json.dumps( - { - "armor_items": len(armor_items), - "accessory_items": len(accessory_items), - "search_depth": search_depth, - } - ), + "data": json.dumps({ + "armor_items": len(armor_items), + "accessory_items": len(accessory_items), + "search_depth": search_depth + }) } - + # Phase 3: Generate combinations progressively armor_combinations = solver._generate_armor_combinations(armor_items) 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"}), + "data": json.dumps({"message": "Search cancelled by client"}) } return - + # Check time limit if time.time() - start_time > limit_config["time_limit"]: yield { "event": "timeout", - "data": json.dumps( - { - "message": f"Search time limit reached ({limit_config['time_limit']}s)" - } - ), + "data": json.dumps({"message": f"Search time limit reached ({limit_config['time_limit']}s)"}) } break - + # Complete suit with accessories - complete_suit = solver._complete_suit_with_accessories( - armor_combo, accessory_items - ) + complete_suit = solver._complete_suit_with_accessories(armor_combo, accessory_items) if complete_suit: # Score the suit scored_suits = solver._score_suits([complete_suit]) if scored_suits: - logger.info( - f"Combination {i}: scored {scored_suits[0]['score']}" - ) + logger.info(f"Combination {i}: scored {scored_suits[0]['score']}") if scored_suits[0]["score"] > 0: found_count += 1 - + # Yield the suit - yield {"event": "suit", "data": json.dumps(scored_suits[0])} - + yield { + "event": "suit", + "data": json.dumps(scored_suits[0]) + } + # Yield progress update if found_count % 5 == 0: yield { - "event": "progress", - "data": json.dumps( - { - "found": found_count, - "checked": i + 1, - "elapsed": round( - time.time() - start_time, 1 - ), - } - ), + "event": "progress", + "data": json.dumps({ + "found": found_count, + "checked": i + 1, + "elapsed": round(time.time() - start_time, 1) + }) } else: logger.info(f"Combination {i}: suit scored 0, skipping") @@ -5132,32 +4229,33 @@ 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"}), + "data": json.dumps({"message": "Search cancelled by client"}) } return - + # Final status yield { "event": "complete", - "data": json.dumps( - { - "total_found": found_count, - "combinations_checked": len(armor_combinations), - "total_time": round(time.time() - start_time, 1), - } - ), + "data": json.dumps({ + "total_found": found_count, + "combinations_checked": len(armor_combinations), + "total_time": round(time.time() - start_time, 1) + }) } - + except Exception as e: logger.error(f"Error in streaming search: {e}") - yield {"event": "error", "data": json.dumps({"message": str(e)})} - + yield { + "event": "error", + "data": json.dumps({"message": str(e)}) + } + return EventSourceResponse(generate_suits()) @@ -5165,38 +4263,22 @@ async def organize_items_by_slot(rows, locked_slots_str=None): """ Organize items by their possible equipment slots. """ - locked_slots = set(locked_slots_str.split(",")) if locked_slots_str else set() - + locked_slots = set(locked_slots_str.split(',')) if locked_slots_str else set() + # Define all possible equipment slots - 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", - ] + 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"] - + all_slots = armor_slots + jewelry_slots + clothing_slots - + # Initialize slot dictionary items_by_slot = {slot: [] for slot in all_slots if slot not in locked_slots} - + # Debug: Track slot assignment for troubleshooting debug_slot_assignments = {} - + for row in rows: # Convert row to item dict item = { @@ -5212,29 +4294,29 @@ async def organize_items_by_slot(rows, locked_slots_str=None): "item_set_id": row["item_set_id"], "spell_names": row["spell_names"] or [], "valid_locations": row["valid_locations"], - "coverage_mask": row["coverage_mask"], + "coverage_mask": row["coverage_mask"] } - + # Determine which slots this item can go in possible_slots = determine_item_slots(item) - + # Debug: Log slot assignment debug_slot_assignments[item["name"]] = { - "coverage_mask": row["coverage_mask"], + "coverage_mask": row["coverage_mask"], "possible_slots": possible_slots, "is_equipped": item["is_equipped"], - "item_set_id": row["item_set_id"], + "item_set_id": row["item_set_id"] } - + # Add item to each possible slot (excluding locked slots) for slot in possible_slots: if slot in items_by_slot: # Skip locked slots items_by_slot[slot].append(item.copy()) - + # Debug: Log slot assignment summary for Barris if any("Barris" in row["character_name"] for row in rows): logger.info(f"DEBUG: Barris slot assignments: {debug_slot_assignments}") - + return items_by_slot @@ -5244,40 +4326,40 @@ def decode_valid_locations_to_jewelry_slots(valid_locations): Based on EquipMask enum from Mag-Plugins. """ slots = [] - + # Jewelry slot mappings (EquipMask values) jewelry_location_map = { - 0x00000001: "Head", # HeadWear - 0x00000008: "Neck", # Necklace - 0x00000010: "Chest", # ChestArmor - 0x00000080: "Abdomen", # AbdomenArmor - 0x00000020: "Upper Arms", # UpperArmArmor - 0x00000040: "Lower Arms", # LowerArmArmor - 0x00000002: "Hands", # HandWear - 0x00000100: "Left Ring", # LeftFinger - 0x00000200: "Right Ring", # RightFinger - 0x00000400: "Left Wrist", # LeftWrist - 0x00000800: "Right Wrist", # RightWrist - 0x00000004: "Feet", # FootWear - 0x00001000: "Upper Legs", # UpperLegArmor - 0x00002000: "Lower Legs", # LowerLegArmor - 0x00008000: "Trinket", # TrinketOne + 0x00000001: "Head", # HeadWear + 0x00000008: "Neck", # Necklace + 0x00000010: "Chest", # ChestArmor + 0x00000080: "Abdomen", # AbdomenArmor + 0x00000020: "Upper Arms", # UpperArmArmor + 0x00000040: "Lower Arms", # LowerArmArmor + 0x00000002: "Hands", # HandWear + 0x00000100: "Left Ring", # LeftFinger + 0x00000200: "Right Ring", # RightFinger + 0x00000400: "Left Wrist", # LeftWrist + 0x00000800: "Right Wrist", # RightWrist + 0x00000004: "Feet", # FootWear + 0x00001000: "Upper Legs", # UpperLegArmor + 0x00002000: "Lower Legs", # LowerLegArmor + 0x00008000: "Trinket" # TrinketOne } - + # Check each jewelry-relevant bit jewelry_bits = { - 0x00000008: "Neck", # Necklace - 0x00000100: "Left Ring", # LeftFinger - 0x00000200: "Right Ring", # RightFinger - 0x00000400: "Left Wrist", # LeftWrist - 0x00000800: "Right Wrist", # RightWrist - 0x00008000: "Trinket", # TrinketOne + 0x00000008: "Neck", # Necklace + 0x00000100: "Left Ring", # LeftFinger + 0x00000200: "Right Ring", # RightFinger + 0x00000400: "Left Wrist", # LeftWrist + 0x00000800: "Right Wrist", # RightWrist + 0x00008000: "Trinket" # TrinketOne } - + for bit_value, slot_name in jewelry_bits.items(): if valid_locations & bit_value: slots.append(slot_name) - + return slots @@ -5286,7 +4368,7 @@ def detect_jewelry_slots_by_name(item_name): Fallback jewelry slot detection based on item name patterns. """ slots = [] - + if "ring" in item_name: slots.extend(["Left Ring", "Right Ring"]) elif any(word in item_name for word in ["bracelet", "wrist"]): @@ -5298,7 +4380,7 @@ def detect_jewelry_slots_by_name(item_name): else: # Default jewelry fallback slots.append("Trinket") - + return slots @@ -5307,12 +4389,12 @@ def determine_item_slots(item): Determine which equipment slots an item can be equipped to. """ slots = [] - + # Handle jewelry by ValidLocations and name patterns if item["object_class"] == 4: # Jewelry valid_locations = item.get("valid_locations", 0) item_name = item["name"].lower() - + # Use ValidLocations bitmask if available (more accurate) if valid_locations and valid_locations > 0: jewelry_slots = decode_valid_locations_to_jewelry_slots(valid_locations) @@ -5324,45 +4406,35 @@ def determine_item_slots(item): else: # Fallback to name-based detection slots.extend(detect_jewelry_slots_by_name(item_name)) - + # Handle armor/clothing by coverage mask or name patterns elif item["object_class"] in [2, 3]: # Armor (2) or Clothing (3) 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") + 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"]) - ) + slots.extend(decode_coverage_to_slots(coverage, item["object_class"], item["name"])) else: # Fallback to name-based detection - if any( - word in item_name for word in ["helm", "cap", "hat", "circlet", "crown"] - ): + if any(word in item_name for word in ["helm", "cap", "hat", "circlet", "crown"]): slots.append("Head") elif any(word in item_name for word in ["robe", "pallium", "robes"]): slots.append("Robe") # Robes have their own dedicated slot elif any(word in item_name for word in ["chest", "cuirass", "hauberk"]): slots.append("Chest") - elif ( - item["object_class"] == 3 - ): # Handle ObjectClass 3 items (clothing and robes) + elif item["object_class"] == 3: # Handle ObjectClass 3 items (clothing and robes) # Use coverage mask detection for all ObjectClass 3 items - slots.extend( - decode_coverage_to_slots( - coverage, item["object_class"], item["name"] - ) - ) + slots.extend(decode_coverage_to_slots(coverage, item["object_class"], item["name"])) elif any(word in item_name for word in ["gauntlet", "glove"]): slots.append("Hands") elif any(word in item_name for word in ["boot", "shoe", "slipper"]): @@ -5376,17 +4448,17 @@ def determine_item_slots(item): slots.append("Upper Arms") elif "cloak" in item_name: slots.append("Cloak") # Cloaks have their own dedicated slot - + return slots if slots else ["Trinket"] # Default fallback -def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=""): +def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''): """ Convert coverage mask to equipment slot names, including clothing. Only classify as clothing if ObjectClass is 3 (Clothing). """ slots = [] - + # 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 @@ -5394,102 +4466,91 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=""): 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: - + # Shirt pattern: OuterwearChest + OuterwearUpperArms + OuterwearLowerArms = 1024 + 4096 + 8192 = 13312 - shirt_pattern = ( - (coverage_mask & 1024) and (coverage_mask & 4096) and (coverage_mask & 8192) - ) + shirt_pattern = (coverage_mask & 1024) and (coverage_mask & 4096) and (coverage_mask & 8192) if shirt_pattern: slots.append("Shirt") return slots # Return early for clothing to avoid adding armor slots - - # Pants pattern: OuterwearUpperLegs + OuterwearLowerLegs + OuterwearAbdomen = 256 + 512 + 2048 = 2816 - pants_pattern = ( - (coverage_mask & 256) and (coverage_mask & 512) and (coverage_mask & 2048) - ) + + # Pants pattern: OuterwearUpperLegs + OuterwearLowerLegs + OuterwearAbdomen = 256 + 512 + 2048 = 2816 + pants_pattern = (coverage_mask & 256) and (coverage_mask & 512) and (coverage_mask & 2048) if pants_pattern: slots.append("Pants") return slots # Return early for clothing to avoid adding armor slots - + # Check for underwear patterns (theoretical) # Shirt = UnderwearChest (8) + UnderwearAbdomen (16) = 24 - if ( - coverage_mask & 8 and coverage_mask & 16 - ): # UnderwearChest + UnderwearAbdomen + if coverage_mask & 8 and coverage_mask & 16: # UnderwearChest + UnderwearAbdomen slots.append("Shirt") return slots - + # Pants = UnderwearUpperLegs (2) + UnderwearLowerLegs (4) + UnderwearAbdomen (16) = 22 - if ( - coverage_mask & 2 and coverage_mask & 4 and coverage_mask & 16 - ): # Full pants pattern + 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 + elif coverage_mask & 2 and coverage_mask & 4: # UnderwearUpperLegs + UnderwearLowerLegs slots.append("Pants") return slots - + # Cloak = 131072 - Exclude cloaks from suit building if coverage_mask & 131072: slots.append("Cloak") # Cloaks have their own dedicated slot return slots - + # Robe detection - Check for robe patterns that might differ from shirt patterns # If an item has chest coverage but is ObjectClass 3 and doesn't match shirt patterns, # and has name indicators, classify as robe - if (coverage_mask & 1024) and any( - word in item_name.lower() for word in ["robe", "pallium"] - ): + if (coverage_mask & 1024) and any(word in item_name.lower() for word in ['robe', 'pallium']): slots.append("Robe") # Robes have their own dedicated slot return slots - + # Armor coverage bit mappings armor_coverage_map = { 1: "Head", - 256: "Upper Legs", # OuterwearUpperLegs - 512: "Lower Legs", # OuterwearLowerLegs - 1024: "Chest", # OuterwearChest - 2048: "Abdomen", # OuterwearAbdomen - 4096: "Upper Arms", # OuterwearUpperArms - 8192: "Lower Arms", # OuterwearLowerArms - 16384: "Head", # Head - 32768: "Hands", # Hands - 65536: "Feet", # Feet + 256: "Upper Legs", # OuterwearUpperLegs + 512: "Lower Legs", # OuterwearLowerLegs + 1024: "Chest", # OuterwearChest + 2048: "Abdomen", # OuterwearAbdomen + 4096: "Upper Arms", # OuterwearUpperArms + 8192: "Lower Arms", # OuterwearLowerArms + 16384: "Head", # Head + 32768: "Hands", # Hands + 65536: "Feet", # Feet } - + # Jewelry coverage bit mappings jewelry_coverage_map = { - 262144: "Neck", # Necklace coverage - 524288: "Left Ring", # Ring coverage - 1048576: "Right Ring", # Ring coverage + 262144: "Neck", # Necklace coverage + 524288: "Left Ring", # Ring coverage + 1048576: "Right Ring", # Ring coverage 2097152: "Left Wrist", # Wrist coverage - 4194304: "Right Wrist", # Wrist coverage - 8388608: "Trinket", # Trinket coverage + 4194304: "Right Wrist", # Wrist coverage + 8388608: "Trinket" # Trinket coverage } - + # Check armor coverage bits for bit_value, slot_name in armor_coverage_map.items(): if coverage_mask & bit_value: slots.append(slot_name) - - # Check jewelry coverage bits + + # Check jewelry coverage bits for bit_value, slot_name in jewelry_coverage_map.items(): if coverage_mask & bit_value: slots.append(slot_name) - + return list(set(slots)) # Remove duplicates + def categorize_items_by_set(items): """Categorize items by equipment set for efficient set-based optimization.""" items_by_set = {} @@ -5505,96 +4566,63 @@ def categorize_items_by_set(items): def categorize_items_by_spell(items, required_spells): """Categorize items by spells for efficient spell-based optimization.""" items_by_spell = {spell: [] for spell in required_spells} - + for item in items: item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: items_by_spell[spell].append(item) - + return items_by_spell -def build_suit_set_priority( - items_by_set, - items_by_spell, - items_by_slot, - primary_set, - secondary_set, - required_spells, -): +def build_suit_set_priority(items_by_set, items_by_spell, items_by_slot, + primary_set, secondary_set, required_spells): """Build suit prioritizing equipment set requirements first.""" suit = { "items": {}, - "stats": { - "primary_set_count": 0, - "secondary_set_count": 0, - "required_spells_found": 0, - }, + "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, "missing": [], - "notes": [], + "notes": [] } used_items = set() - + # Priority 1: Place primary set items if primary_set and int(primary_set) in items_by_set: - primary_items = sorted( - items_by_set[int(primary_set)], - key=lambda x: x.get("armor_level", 0), - reverse=True, - ) - placed = place_set_items_optimally( - suit, primary_items, 5, used_items, items_by_slot - ) + primary_items = sorted(items_by_set[int(primary_set)], + key=lambda x: x.get("armor_level", 0), reverse=True) + placed = place_set_items_optimally(suit, primary_items, 5, used_items, items_by_slot) suit["stats"]["primary_set_count"] = placed - + # Priority 2: Place secondary set items if secondary_set and int(secondary_set) in items_by_set: - secondary_items = sorted( - items_by_set[int(secondary_set)], - key=lambda x: x.get("armor_level", 0), - reverse=True, - ) - placed = place_set_items_optimally( - suit, secondary_items, 4, used_items, items_by_slot - ) + secondary_items = sorted(items_by_set[int(secondary_set)], + key=lambda x: x.get("armor_level", 0), reverse=True) + placed = place_set_items_optimally(suit, secondary_items, 4, used_items, items_by_slot) suit["stats"]["secondary_set_count"] = placed - + # Priority 3: Fill remaining slots with best available items fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells) - + return suit -def build_suit_spell_priority( - items_by_set, - items_by_spell, - items_by_slot, - primary_set, - secondary_set, - required_spells, -): +def build_suit_spell_priority(items_by_set, items_by_spell, items_by_slot, + primary_set, secondary_set, required_spells): """Build suit prioritizing spell requirements first.""" suit = { "items": {}, - "stats": { - "primary_set_count": 0, - "secondary_set_count": 0, - "required_spells_found": 0, - }, + "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, "missing": [], - "notes": [], + "notes": [] } used_items = set() - + # Priority 1: Place items with required spells for spell in required_spells: if spell in items_by_spell and items_by_spell[spell]: - spell_items = sorted( - items_by_spell[spell], - key=lambda x: x.get("armor_level", 0), - reverse=True, - ) + spell_items = sorted(items_by_spell[spell], + key=lambda x: x.get("armor_level", 0), reverse=True) for item in spell_items[:2]: # Limit to prevent spell hogging if item["item_id"] not in used_items: slots = determine_item_slots(item) @@ -5604,141 +4632,107 @@ def build_suit_spell_priority( used_items.add(item["item_id"]) suit["stats"]["required_spells_found"] += 1 break - + # Priority 2: Add set items to remaining slots if primary_set and int(primary_set) in items_by_set: primary_items = items_by_set[int(primary_set)] - placed = place_set_items_optimally( - suit, primary_items, 5, used_items, items_by_slot, replace_ok=False - ) + placed = place_set_items_optimally(suit, primary_items, 5, used_items, items_by_slot, replace_ok=False) suit["stats"]["primary_set_count"] = placed - + if secondary_set and int(secondary_set) in items_by_set: secondary_items = items_by_set[int(secondary_set)] - placed = place_set_items_optimally( - suit, secondary_items, 4, used_items, items_by_slot, replace_ok=False - ) + placed = place_set_items_optimally(suit, secondary_items, 4, used_items, items_by_slot, replace_ok=False) suit["stats"]["secondary_set_count"] = placed - + # Priority 3: Fill remaining slots fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells) - + return suit -def build_suit_balanced( - items_by_set, - items_by_spell, - items_by_slot, - primary_set, - secondary_set, - required_spells, -): +def build_suit_balanced(items_by_set, items_by_spell, items_by_slot, + primary_set, secondary_set, required_spells): """Build suit using balanced approach between sets and spells.""" suit = { "items": {}, - "stats": { - "primary_set_count": 0, - "secondary_set_count": 0, - "required_spells_found": 0, - }, + "stats": {"primary_set_count": 0, "secondary_set_count": 0, "required_spells_found": 0}, "missing": [], - "notes": [], + "notes": [] } used_items = set() - + # Interleave set and spell placement - 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", - ] + 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"] - + set_items = [] if primary_set and int(primary_set) in items_by_set: set_items.extend(items_by_set[int(primary_set)][:5]) if secondary_set and int(secondary_set) in items_by_set: set_items.extend(items_by_set[int(secondary_set)][:4]) - + # Sort all candidate items by combined value (armor + spell count) def item_value(item): - spell_bonus = ( - len([s for s in item.get("spell_names", []) if s in required_spells]) * 100 - ) + spell_bonus = len([s for s in item.get("spell_names", []) if s in required_spells]) * 100 return item.get("armor_level", 0) + spell_bonus - + all_candidates = [] for slot_items in items_by_slot.values(): all_candidates.extend(slot_items) - + # Remove duplicates and sort by value unique_candidates = {item["item_id"]: item for item in all_candidates}.values() sorted_candidates = sorted(unique_candidates, key=item_value, reverse=True) - + # Place items greedily by value for item in sorted_candidates: if item["item_id"] in used_items: continue - + slots = determine_item_slots(item) for slot in slots: if slot not in suit["items"]: suit["items"][slot] = item used_items.add(item["item_id"]) - + # Update stats item_set = item.get("item_set_id") if primary_set and item_set == int(primary_set): suit["stats"]["primary_set_count"] += 1 elif secondary_set and item_set == int(secondary_set): suit["stats"]["secondary_set_count"] += 1 - + item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: suit["stats"]["required_spells_found"] += 1 break - + return suit -def place_set_items_optimally( - suit, set_items, target_count, used_items, items_by_slot, replace_ok=True -): +def place_set_items_optimally(suit, set_items, target_count, used_items, items_by_slot, replace_ok=True): """Place set items optimally in available slots.""" placed_count = 0 - + # Sort items by value (armor level, spell count, etc.) def item_value(item): return item.get("armor_level", 0) + len(item.get("spell_names", [])) * 10 - + sorted_items = sorted(set_items, key=item_value, reverse=True) - + for item in sorted_items: if placed_count >= target_count: break - + if item["item_id"] in used_items: continue - + slots = determine_item_slots(item) placed = False - + for slot in slots: # Try to place in empty slot first if slot not in suit["items"]: @@ -5755,59 +4749,37 @@ def place_set_items_optimally( used_items.add(item["item_id"]) placed = True break - + if placed: continue - + return placed_count def fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spells): """Fill remaining empty slots with best available items.""" - 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", - ] + 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"] - + for slot in armor_slots + jewelry_slots + clothing_slots: if slot in suit["items"]: # Slot already filled continue - + if slot not in items_by_slot or not items_by_slot[slot]: continue - + # Find best available item for this slot - available_items = [ - item for item in items_by_slot[slot] if item["item_id"] not in used_items - ] - + available_items = [item for item in items_by_slot[slot] + if item["item_id"] not in used_items] + if available_items: # Score items by armor + spell value def item_value(item): - spell_bonus = ( - len( - [s for s in item.get("spell_names", []) if s in required_spells] - ) - * 50 - ) + spell_bonus = len([s for s in item.get("spell_names", []) if s in required_spells]) * 50 return item.get("armor_level", 0) + spell_bonus - + best_item = max(available_items, key=item_value) suit["items"][slot] = best_item used_items.add(best_item["item_id"]) @@ -5816,16 +4788,14 @@ def fill_remaining_slots_optimally(suit, items_by_slot, used_items, required_spe def is_duplicate_suit(new_suit, existing_suits): """Check if this suit is substantially the same as an existing one.""" new_items = set(item["item_id"] for item in new_suit["items"].values()) - + for existing_suit in existing_suits: - existing_items = set( - item["item_id"] for item in existing_suit["items"].values() - ) - + existing_items = set(item["item_id"] for item in existing_suit["items"].values()) + # If 80% or more items are the same, consider it a duplicate if len(new_items & existing_items) / max(len(new_items), 1) >= 0.8: return True - + return False @@ -5837,98 +4807,99 @@ def calculate_suit_stats(suit, primary_set, secondary_set, required_spells): "total_damage_rating": 0, "primary_set_count": 0, "secondary_set_count": 0, - "required_spells_found": 0, + "required_spells_found": 0 } - + found_spells = set() - + for item in suit["items"].values(): # Accumulate stats suit["stats"]["total_armor"] += item.get("armor_level", 0) - suit["stats"]["total_crit_damage"] += item.get("crit_damage_rating", 0) + suit["stats"]["total_crit_damage"] += item.get("crit_damage_rating", 0) suit["stats"]["total_damage_rating"] += item.get("damage_rating", 0) - + # Count set pieces item_set = item.get("item_set_id") if primary_set and item_set == int(primary_set): suit["stats"]["primary_set_count"] += 1 if secondary_set and item_set == int(secondary_set): suit["stats"]["secondary_set_count"] += 1 - + # Count unique required spells item_spells = item.get("spell_names", []) for spell in required_spells: if spell in item_spells: found_spells.add(spell) - + suit["stats"]["required_spells_found"] = len(found_spells) class ConstraintSatisfactionSolver: """ MagSuitbuilder-inspired two-phase constraint satisfaction solver. - + Phase 1: ArmorSearcher - Strict set filtering for armor pieces Phase 2: AccessorySearcher - Spell optimization for jewelry/clothing """ - + def __init__(self, characters, constraints): self.characters = characters self.constraints = constraints - self.primary_set = constraints.get("primary_set") - self.secondary_set = constraints.get("secondary_set") - self.min_armor = constraints.get("min_armor", 0) - self.min_crit_damage = constraints.get("min_crit_damage", 0) - self.min_damage_rating = constraints.get("min_damage_rating", 0) - self.min_heal_boost = constraints.get("min_heal_boost", 0) - self.legendary_cantrips = constraints.get("legendary_cantrips", []) - self.protection_spells = constraints.get("protection_spells", []) - self.equipment_status_filter = constraints.get( - "equipment_status_filter", "both" - ) - + self.primary_set = constraints.get('primary_set') + self.secondary_set = constraints.get('secondary_set') + self.min_armor = constraints.get('min_armor', 0) + self.min_crit_damage = constraints.get('min_crit_damage', 0) + self.min_damage_rating = constraints.get('min_damage_rating', 0) + self.min_heal_boost = constraints.get('min_heal_boost', 0) + self.legendary_cantrips = constraints.get('legendary_cantrips', []) + self.protection_spells = constraints.get('protection_spells', []) + self.equipment_status_filter = constraints.get('equipment_status_filter', 'both') + + async def find_optimal_suits(self): """Find optimal equipment combinations using MagSuitbuilder's two-phase algorithm""" try: # Phase 1: ArmorSearcher - Get armor pieces with strict set filtering armor_items = await self._get_armor_items_with_set_filtering() - + if not armor_items: return { "suits": [], "message": "No armor items found matching set requirements", - "total_found": 0, + "total_found": 0 } - + # Phase 2: AccessorySearcher - Get jewelry/clothing (no set restrictions) accessory_items = await self._get_accessory_items() - + # Generate armor combinations (9 slots max) armor_combinations = self._generate_armor_combinations(armor_items) - + # For each viable armor combination, find best accessories suits = [] for armor_combo in armor_combinations[:100]: # Limit to prevent timeout - complete_suit = self._complete_suit_with_accessories( - armor_combo, accessory_items - ) + complete_suit = self._complete_suit_with_accessories(armor_combo, accessory_items) if complete_suit: suits.append(complete_suit) - + # Score and rank suits scored_suits = self._score_suits(suits) - + return { "suits": scored_suits[:20], "total_found": len(scored_suits), "armor_items_available": len(armor_items), - "accessory_items_available": len(accessory_items), + "accessory_items_available": len(accessory_items) } - + except Exception as e: print(f"Error in constraint solver: {e}") - return {"suits": [], "message": f"Error: {str(e)}", "total_found": 0} - + return { + "suits": [], + "message": f"Error: {str(e)}", + "total_found": 0 + } + def _item_meets_constraints(self, item): """Check if an item contributes to meeting the specified constraints""" # Convert item data for consistency @@ -5937,38 +4908,34 @@ class ConstraintSatisfactionSolver: item_spell_list = [s.strip() for s in item_spells.split(",") if s.strip()] else: item_spell_list = item_spells or [] - + item_armor = item.get("armor_level", 0) or 0 item_crit = int(item.get("gear_crit_damage", 0) or 0) item_damage = int(item.get("gear_damage_rating", 0) or 0) item_heal = int(item.get("gear_heal_boost", 0) or 0) item_set = int(item.get("item_set_id", 0) or 0) - + # If no constraints specified, item is useful if it has any meaningful stats has_any_constraints = ( - self.primary_set - or self.secondary_set - or self.legendary_cantrips - or self.protection_spells - or self.min_armor > 0 - or self.min_crit_damage > 0 - or self.min_damage_rating > 0 - or self.min_heal_boost > 0 + self.primary_set or self.secondary_set or + self.legendary_cantrips or self.protection_spells or + self.min_armor > 0 or self.min_crit_damage > 0 or + self.min_damage_rating > 0 or self.min_heal_boost > 0 ) - + if not has_any_constraints: # No constraints specified - any item with decent stats is useful return item_armor > 0 or item_crit > 0 or item_damage > 0 or item_heal > 0 - + # Check if item contributes to any constraint contributes_to_constraint = False - + # Set constraints if self.primary_set and item_set == self.primary_set: contributes_to_constraint = True if self.secondary_set and item_set == self.secondary_set: contributes_to_constraint = True - + # Spell constraints required_spell_names = self.legendary_cantrips + self.protection_spells if required_spell_names: @@ -5977,28 +4944,24 @@ class ConstraintSatisfactionSolver: # Fuzzy matching like in scoring spell_words = item_spell.lower().split() required_words = required_spell.lower().split() - matches_all_words = all( - any(req_word in spell_word for spell_word in spell_words) - for req_word in required_words - ) + matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) + for req_word in required_words) if matches_all_words: contributes_to_constraint = True break if contributes_to_constraint: break - + # Stat constraints - item helps if it has meaningful amounts of required stats if self.min_armor > 0 and item_armor >= 100: # Meaningful armor contribution contributes_to_constraint = True if self.min_crit_damage > 0 and item_crit >= 5: # Meaningful crit contribution contributes_to_constraint = True - if ( - self.min_damage_rating > 0 and item_damage >= 5 - ): # Meaningful damage contribution + if self.min_damage_rating > 0 and item_damage >= 5: # Meaningful damage contribution contributes_to_constraint = True if self.min_heal_boost > 0 and item_heal >= 5: # Meaningful heal contribution contributes_to_constraint = True - + return contributes_to_constraint async def _get_armor_items_with_set_filtering(self): @@ -6021,49 +4984,47 @@ class ConstraintSatisfactionSolver: WHERE i.character_name = ANY(:characters) AND cs.armor_level > 0 """ - - logger.info( - f"Filtering armor for constraints: primary_set={self.primary_set}, secondary_set={self.secondary_set}, cantrips={self.legendary_cantrips}, wards={self.protection_spells}" - ) - + + logger.info(f"Filtering armor for constraints: primary_set={self.primary_set}, secondary_set={self.secondary_set}, cantrips={self.legendary_cantrips}, wards={self.protection_spells}") + # Apply set filtering if any sets are specified set_filters = [] params = {"characters": self.characters} - + if self.primary_set: set_filters.append("enh.item_set = :primary_set") params["primary_set"] = str(self.primary_set) - + if self.secondary_set: set_filters.append("enh.item_set = :secondary_set") params["secondary_set"] = str(self.secondary_set) - + if set_filters: query += f" AND ({' OR '.join(set_filters)})" logger.info(f"Applied set filtering: {' OR '.join(set_filters)}") else: logger.info("No set filtering applied - will use all armor items") - + # Apply equipment status filtering if self.equipment_status_filter == "equipped_only": query += " AND i.current_wielded_location > 0" elif self.equipment_status_filter == "inventory_only": query += " AND i.current_wielded_location = 0" # "both" requires no additional filter - + query += """ GROUP BY i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class, i.current_wielded_location, i.timestamp, enh.item_set, cs.armor_level, rd.int_values->>'374', rd.int_values->>'370', rd.int_values->>'372', rd.int_values->>'218103821' ORDER BY cs.armor_level DESC """ - + async with database.transaction(): rows = await database.fetch_all(query, params) - + items = [] - spells_enum = ENUM_MAPPINGS.get("spells", {}) - + spells_enum = ENUM_MAPPINGS.get('spells', {}) + for row in rows: item = dict(row) # Apply proper slot detection (including clothing) @@ -6071,44 +5032,36 @@ class ConstraintSatisfactionSolver: "object_class": item.get("object_class"), "coverage_mask": item.get("coverage", 0), "name": item.get("name", ""), - "valid_locations": item.get("valid_locations", 0), + "valid_locations": item.get("valid_locations", 0) } slots = determine_item_slots(item_for_slots) item["slot_name"] = ", ".join(slots) - + # Convert spell IDs to spell names spell_ids_str = item.get("spell_ids", "") spell_names = [] if spell_ids_str: - spell_ids = [ - int(sid.strip()) for sid in spell_ids_str.split(",") if sid.strip() - ] + spell_ids = [int(sid.strip()) for sid in spell_ids_str.split(',') if sid.strip()] for spell_id in spell_ids: spell_data = spells_enum.get(spell_id) if spell_data and isinstance(spell_data, dict): - spell_name = spell_data.get("name", f"Unknown Spell {spell_id}") + spell_name = spell_data.get('name', f'Unknown Spell {spell_id}') spell_names.append(spell_name) elif spell_data: spell_names.append(str(spell_data)) - + item["spell_names"] = ", ".join(spell_names) - + # CRITICAL: Only include items that contribute to constraints if self._item_meets_constraints(item): items.append(item) - logger.debug( - f"Included armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...)" - ) + logger.debug(f"Included armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...)") else: - logger.debug( - f"Filtered out armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...) - doesn't meet constraints" - ) - - logger.info( - f"Armor filtering: {len(items)} items meet constraints out of {len(rows)} total armor items" - ) + logger.debug(f"Filtered out armor: {item['name']} (set: {item.get('item_set_id')}, spells: {item['spell_names'][:50]}...) - doesn't meet constraints") + + logger.info(f"Armor filtering: {len(items)} items meet constraints out of {len(rows)} total armor items") return items - + async def _get_accessory_items(self): """Phase 2: AccessorySearcher - Get jewelry/clothing with constraint filtering""" query = """ @@ -6136,14 +5089,14 @@ class ConstraintSatisfactionSolver: OR (rd.int_values->>'218103821')::int & 6 = 6 -- Underwear Pants ) AND LOWER(i.name) NOT LIKE '%robe%' AND LOWER(i.name) NOT LIKE '%pallium%')) -- Exclude robes and palliums from suit building """ - + # Apply equipment status filtering if self.equipment_status_filter == "equipped_only": query += " AND i.current_wielded_location > 0" elif self.equipment_status_filter == "inventory_only": query += " AND i.current_wielded_location = 0" # "both" requires no additional filter - + query += """ GROUP BY i.id, i.character_name, i.item_id, i.name, i.icon, i.object_class, i.current_wielded_location, i.timestamp, @@ -6152,15 +5105,15 @@ class ConstraintSatisfactionSolver: rd.int_values->>'370', rd.int_values->>'372' ORDER BY cs.armor_level DESC """ - + params = {"characters": self.characters} - + async with database.transaction(): rows = await database.fetch_all(query, params) - + items = [] - spells_enum = ENUM_MAPPINGS.get("spells", {}) - + spells_enum = ENUM_MAPPINGS.get('spells', {}) + for row in rows: item = dict(row) # Apply proper slot detection (including clothing) @@ -6168,89 +5121,72 @@ class ConstraintSatisfactionSolver: "object_class": item.get("object_class"), "coverage_mask": item.get("coverage", 0), "name": item.get("name", ""), - "valid_locations": item.get("valid_locations", 0), + "valid_locations": item.get("valid_locations", 0) } slots = determine_item_slots(item_for_slots) item["slot_name"] = ", ".join(slots) - + # Convert spell IDs to spell names spell_ids_str = item.get("spell_ids", "") spell_names = [] if spell_ids_str: - spell_ids = [ - int(sid.strip()) for sid in spell_ids_str.split(",") if sid.strip() - ] + spell_ids = [int(sid.strip()) for sid in spell_ids_str.split(',') if sid.strip()] for spell_id in spell_ids: spell_data = spells_enum.get(spell_id) if spell_data and isinstance(spell_data, dict): - spell_name = spell_data.get("name", f"Unknown Spell {spell_id}") + spell_name = spell_data.get('name', f'Unknown Spell {spell_id}') spell_names.append(spell_name) elif spell_data: spell_names.append(str(spell_data)) - + item["spell_names"] = ", ".join(spell_names) - - # CRITICAL: Only include accessories that contribute to constraints + + # CRITICAL: Only include accessories that contribute to constraints if self._item_meets_constraints(item): items.append(item) - logger.debug( - f"Included accessory: {item['name']} (spells: {item['spell_names'][:50]}..., crit: {item.get('gear_crit_damage', 0)}, damage: {item.get('gear_damage_rating', 0)})" - ) + logger.debug(f"Included accessory: {item['name']} (spells: {item['spell_names'][:50]}..., crit: {item.get('gear_crit_damage', 0)}, damage: {item.get('gear_damage_rating', 0)})") else: - logger.debug( - f"Filtered out accessory: {item['name']} (spells: {item['spell_names'][:50]}...) - doesn't meet constraints" - ) - - logger.info( - f"Accessory filtering: {len(items)} items meet constraints out of {len(rows)} total accessory items" - ) + logger.debug(f"Filtered out accessory: {item['name']} (spells: {item['spell_names'][:50]}...) - doesn't meet constraints") + + logger.info(f"Accessory filtering: {len(items)} items meet constraints out of {len(rows)} total accessory items") return items - + def _generate_armor_combinations(self, armor_items): """Generate ALL viable armor combinations using MagSuitbuilder's recursive approach""" if not armor_items: logger.warning("No armor items provided to _generate_armor_combinations") return [] - + # Group armor by slot armor_by_slot = {} for item in armor_items: slots = item.get("slot_name", "").split(", ") for slot in slots: slot = slot.strip() - if slot and slot not in [ - "Neck", - "Left Ring", - "Right Ring", - "Left Wrist", - "Right Wrist", - "Trinket", - ]: + if slot and slot not in ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"]: if slot not in armor_by_slot: armor_by_slot[slot] = [] armor_by_slot[slot].append(item) - - logger.info( - f"Armor grouped by slot: {[(slot, len(items)) for slot, items in armor_by_slot.items()]}" - ) - + + logger.info(f"Armor grouped by slot: {[(slot, len(items)) for slot, items in armor_by_slot.items()]}") + # Sort slots by number of items (least first for faster pruning) slot_list = sorted(armor_by_slot.items(), key=lambda x: len(x[1])) - + # Initialize search state self.combinations_found = [] self.best_scores = {} # Track best scores to prune bad branches self.max_combinations = 20 # Limit to prevent timeout - + # Start recursive search current_combo = {} self._recursive_armor_search(slot_list, 0, current_combo, set()) - + logger.info(f"Generated {len(self.combinations_found)} armor combinations") - + # Return unique combinations sorted by score - return self.combinations_found[: self.max_combinations] - + return self.combinations_found[:self.max_combinations] + def _recursive_armor_search(self, slot_list, index, current_combo, used_items): """Recursive backtracking search (MagSuitbuilder approach)""" # Base case: we've processed all slots @@ -6258,7 +5194,7 @@ class ConstraintSatisfactionSolver: if current_combo: # Calculate combo score combo_score = self._calculate_combo_score(current_combo) - + # Only keep if it's a good combination if combo_score >= 50: # Minimum threshold # Check if we already have a similar combo @@ -6266,21 +5202,20 @@ class ConstraintSatisfactionSolver: self.combinations_found.append(dict(current_combo)) # Sort by score to keep best ones self.combinations_found.sort( - key=lambda x: self._calculate_combo_score(x), reverse=True + key=lambda x: self._calculate_combo_score(x), + reverse=True ) # Keep only top combinations if len(self.combinations_found) > self.max_combinations * 2: - self.combinations_found = self.combinations_found[ - : self.max_combinations - ] + self.combinations_found = self.combinations_found[:self.max_combinations] return - + # Stop if we've found enough good combinations if len(self.combinations_found) >= self.max_combinations: return - + slot_name, items = slot_list[index] - + # Try each item in this slot for item in items[:5]: # Limit items per slot to prevent explosion if item["item_id"] not in used_items: @@ -6289,110 +5224,94 @@ class ConstraintSatisfactionSolver: # Push: Add item to current combination current_combo[slot_name] = item used_items.add(item["item_id"]) - + # Recurse to next slot - self._recursive_armor_search( - slot_list, index + 1, current_combo, used_items - ) - + self._recursive_armor_search(slot_list, index + 1, current_combo, used_items) + # Pop: Remove item (backtrack) del current_combo[slot_name] used_items.remove(item["item_id"]) - + # Also try skipping this slot entirely (empty slot) self._recursive_armor_search(slot_list, index + 1, current_combo, used_items) - + def _calculate_combo_score(self, combo): """Quick score calculation for pruning""" primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum( - 1 - for item in combo.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int - ) - secondary_count = sum( - 1 - for item in combo.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int - ) - + + primary_count = sum(1 for item in combo.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int) + secondary_count = sum(1 for item in combo.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int) + score = 0 if primary_count >= 5: score += 50 else: score += primary_count * 8 - + if secondary_count >= 4: score += 40 else: score += secondary_count * 8 - + return score - + def _should_try_item(self, item, current_combo): """Check if item is worth trying - must meet constraints and set logic""" # CRITICAL: Item must meet constraints to be considered if not self._item_meets_constraints(item): return False - + primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None item_set_int = int(item.get("item_set_id", 0) or 0) - + # Count current sets - primary_count = sum( - 1 - for i in current_combo.values() - if int(i.get("item_set_id", 0) or 0) == primary_set_int - ) - secondary_count = sum( - 1 - for i in current_combo.values() - if int(i.get("item_set_id", 0) or 0) == secondary_set_int - ) - + primary_count = sum(1 for i in current_combo.values() + if int(i.get("item_set_id", 0) or 0) == primary_set_int) + secondary_count = sum(1 for i in current_combo.values() + if int(i.get("item_set_id", 0) or 0) == secondary_set_int) + # Apply MagSuitbuilder's logic but with constraint checking if item_set_int == primary_set_int and primary_count < 5: return True elif item_set_int == secondary_set_int and secondary_count < 4: return True - elif ( - not item_set_int - ): # Non-set items allowed if we have room and they meet constraints + elif not item_set_int: # Non-set items allowed if we have room and they meet constraints return len(current_combo) < 9 else: # Wrong set item return False - + def _is_duplicate_combo(self, combo): """Check if we already have this combination""" combo_items = set(item["item_id"] for item in combo.values()) - + for existing in self.combinations_found: existing_items = set(item["item_id"] for item in existing.values()) if combo_items == existing_items: return True return False - + def _build_optimal_combination(self, armor_by_slot, strategy="balanced"): """Build a single combination using specified strategy""" combination = {} primary_count = 0 secondary_count = 0 - + primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - + # Process slots in order for slot, items in armor_by_slot.items(): best_item = None best_score = -1 - + for item in items: item_set_int = int(item.get("item_set_id", 0) or 0) score = 0 - + if strategy == "armor": # Prioritize armor level score = item.get("armor_level", 0) @@ -6400,7 +5319,7 @@ class ConstraintSatisfactionSolver: score += 50 elif item_set_int == secondary_set_int: score += 30 - + elif strategy == "primary": # Maximize primary set if item_set_int == primary_set_int and primary_count < 5: @@ -6409,7 +5328,7 @@ class ConstraintSatisfactionSolver: score = 500 else: score = item.get("armor_level", 0) - + elif strategy == "balanced": # Balance sets according to requirements if item_set_int == primary_set_int and primary_count < 5: @@ -6418,11 +5337,11 @@ class ConstraintSatisfactionSolver: score = 600 else: score = item.get("armor_level", 0) / 10 - + if score > best_score: best_item = item best_score = score - + if best_item: combination[slot] = best_item item_set_int = int(best_item.get("item_set_id", 0) or 0) @@ -6430,57 +5349,49 @@ class ConstraintSatisfactionSolver: primary_count += 1 elif item_set_int == secondary_set_int: secondary_count += 1 - + return combination if combination else None - + def _build_equipped_preferred_combination(self, armor_by_slot): """Build combination preferring currently equipped items""" combination = {} - + for slot, items in armor_by_slot.items(): # Prefer equipped items - equipped_items = [ - item for item in items if item.get("current_wielded_location", 0) > 0 - ] + equipped_items = [item for item in items if item.get("current_wielded_location", 0) > 0] if equipped_items: # Take the best equipped item for this slot - combination[slot] = max( - equipped_items, key=lambda x: x.get("armor_level", 0) - ) + combination[slot] = max(equipped_items, key=lambda x: x.get("armor_level", 0)) elif items: # Fall back to best available combination[slot] = items[0] - + return combination if combination else None - + def _complete_suit_with_accessories(self, armor_combo, accessory_items): """Complete armor combination with systematic jewelry optimization""" complete_suit = {"items": dict(armor_combo)} - + # Only optimize accessories if there are accessory-related constraints has_accessory_constraints = ( - self.legendary_cantrips - or self.protection_spells - or self.min_crit_damage > 0 - or self.min_damage_rating > 0 - or self.min_heal_boost > 0 + self.legendary_cantrips or + self.protection_spells or + self.min_crit_damage > 0 or + self.min_damage_rating > 0 or + self.min_heal_boost > 0 ) - + if has_accessory_constraints: # Systematically optimize jewelry slots - jewelry_items = [ - item for item in accessory_items if item.get("object_class") == 4 - ] + jewelry_items = [item for item in accessory_items if item.get("object_class") == 4] self._optimize_jewelry_systematically(complete_suit, jewelry_items) - + # Also optimize clothing slots - clothing_items = [ - item for item in accessory_items if item.get("object_class") == 3 - ] + clothing_items = [item for item in accessory_items if item.get("object_class") == 3] self._optimize_clothing_systematically(complete_suit, clothing_items) - + return complete_suit - + def _optimize_jewelry_systematically(self, suit, jewelry_items): """ Systematically optimize all 6 jewelry slots for maximum benefit. @@ -6488,17 +5399,17 @@ class ConstraintSatisfactionSolver: """ # Group jewelry by slot jewelry_by_slot = self._group_jewelry_by_slot(jewelry_items) - + # Define jewelry slot priority (amulets often have best spells) jewelry_slot_priority = [ - "Neck", # Amulets/necklaces often have legendary cantrips - "Left Ring", # Rings often have high ratings + "Neck", # Amulets/necklaces often have legendary cantrips + "Left Ring", # Rings often have high ratings "Right Ring", - "Left Wrist", # Bracelets + "Left Wrist", # Bracelets "Right Wrist", - "Trinket", # Special items + "Trinket" # Special items ] - + # Track spells already covered by armor covered_spells = set() for item in suit["items"].values(): @@ -6508,7 +5419,7 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + # Optimize each jewelry slot for slot in jewelry_slot_priority: if slot in jewelry_by_slot and jewelry_by_slot[slot]: @@ -6517,7 +5428,7 @@ class ConstraintSatisfactionSolver: ) if best_item: suit["items"][slot] = best_item - + # Update covered spells item_spells = best_item.get("spell_names", "") if item_spells: @@ -6525,52 +5436,44 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + def _group_jewelry_by_slot(self, jewelry_items): """Group jewelry items by their possible slots""" jewelry_by_slot = { - "Neck": [], - "Left Ring": [], - "Right Ring": [], - "Left Wrist": [], - "Right Wrist": [], - "Trinket": [], + "Neck": [], "Left Ring": [], "Right Ring": [], + "Left Wrist": [], "Right Wrist": [], "Trinket": [] } - + for item in jewelry_items: possible_slots = determine_item_slots(item) for slot in possible_slots: if slot in jewelry_by_slot: jewelry_by_slot[slot].append(item) - + return jewelry_by_slot - - def _find_best_jewelry_for_slot( - self, slot, slot_items, current_suit, covered_spells - ): + + def _find_best_jewelry_for_slot(self, slot, slot_items, current_suit, covered_spells): """Find the best jewelry item for a specific slot""" if not slot_items: return None - + required_spells = self.legendary_cantrips + self.protection_spells best_item = None best_score = -1 - + for item in slot_items: - score = self._calculate_jewelry_item_score( - item, required_spells, covered_spells - ) + score = self._calculate_jewelry_item_score(item, required_spells, covered_spells) if score > best_score: best_score = score best_item = item - + # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) # Score of 0 means it doesn't meet constraints, don't use it if best_score <= 0: return None - + return best_item - + def _optimize_clothing_systematically(self, suit, clothing_items): """ Systematically optimize clothing slots (Shirt and Pants) for maximum benefit. @@ -6578,10 +5481,10 @@ class ConstraintSatisfactionSolver: """ # Group clothing by slot clothing_by_slot = self._group_clothing_by_slot(clothing_items) - + # Define clothing slot priority clothing_slot_priority = ["Shirt", "Pants"] - + # Track spells already covered by armor and jewelry covered_spells = set() for item in suit["items"].values(): @@ -6591,7 +5494,7 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + # Optimize each clothing slot for slot in clothing_slot_priority: if slot in clothing_by_slot and clothing_by_slot[slot]: @@ -6600,7 +5503,7 @@ class ConstraintSatisfactionSolver: ) if best_item: suit["items"][slot] = best_item - + # Update covered spells item_spells = best_item.get("spell_names", "") if item_spells: @@ -6608,53 +5511,49 @@ class ConstraintSatisfactionSolver: covered_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): covered_spells.update(item_spells) - + def _group_clothing_by_slot(self, clothing_items): """Group clothing items by their possible slots""" clothing_by_slot = {"Shirt": [], "Pants": []} - + for item in clothing_items: possible_slots = determine_item_slots(item) for slot in possible_slots: if slot in clothing_by_slot: clothing_by_slot[slot].append(item) - + return clothing_by_slot - - def _find_best_clothing_for_slot( - self, slot, slot_items, current_suit, covered_spells - ): + + def _find_best_clothing_for_slot(self, slot, slot_items, current_suit, covered_spells): """Find the best clothing item for a specific slot""" if not slot_items: return None - + required_spells = self.legendary_cantrips + self.protection_spells best_item = None best_score = -1 - + for item in slot_items: - score = self._calculate_clothing_item_score( - item, required_spells, covered_spells - ) + score = self._calculate_clothing_item_score(item, required_spells, covered_spells) if score > best_score: best_score = score best_item = item - - # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) + + # CRITICAL: Only return item if it has a meaningful score (contributes to constraints) # Score of 0 means it doesn't meet constraints, don't use it if best_score <= 0: return None - + return best_item - + def _calculate_clothing_item_score(self, item, required_spells, covered_spells): """Calculate optimization score for a clothing item""" # CRITICAL: Items that don't meet constraints get score 0 if not self._item_meets_constraints(item): return 0 - + score = 0 - + # Get item spells item_spells = set() spell_data = item.get("spell_names", "") @@ -6663,40 +5562,37 @@ class ConstraintSatisfactionSolver: item_spells.update(spell_data.split(", ")) elif isinstance(spell_data, list): item_spells.update(spell_data) - + # High bonus for required spells that aren't covered yet for spell in required_spells: for item_spell in item_spells: - if ( - spell.lower() in item_spell.lower() - and item_spell not in covered_spells - ): + if spell.lower() in item_spell.lower() and item_spell not in covered_spells: score += 100 # Very high bonus for uncovered required spells - + # Bonus for any legendary spells for spell in item_spells: if "legendary" in spell.lower(): score += 20 - + # Rating bonuses (clothing can have ratings too, only count if meeting constraints) score += (item.get("gear_crit_damage", 0) or 0) * 2 score += (item.get("gear_damage_rating", 0) or 0) * 2 score += (item.get("gear_heal_boost", 0) or 0) * 1 - + # Prefer unequipped items slightly if item.get("current_wielded_location", 0) == 0: score += 5 - + return score - + def _calculate_jewelry_item_score(self, item, required_spells, covered_spells): """Calculate optimization score for a jewelry item""" # CRITICAL: Items that don't meet constraints get score 0 if not self._item_meets_constraints(item): return 0 - + score = 0 - + # Get item spells item_spells = set() spell_data = item.get("spell_names", "") @@ -6705,64 +5601,48 @@ class ConstraintSatisfactionSolver: item_spells.update(spell_data.split(", ")) elif isinstance(spell_data, list): item_spells.update(spell_data) - + # High bonus for required spells that aren't covered yet for spell in required_spells: for item_spell in item_spells: - if ( - spell.lower() in item_spell.lower() - and item_spell not in covered_spells - ): + if spell.lower() in item_spell.lower() and item_spell not in covered_spells: score += 100 # Very high bonus for uncovered required spells - + # Bonus for any legendary spells for spell in item_spells: if "legendary" in spell.lower(): score += 20 - + # Rating bonuses (only count if meeting constraints) score += (item.get("gear_crit_damage", 0) or 0) * 2 score += (item.get("gear_damage_rating", 0) or 0) * 2 score += (item.get("gear_heal_boost", 0) or 0) * 1 - + # Prefer unequipped items slightly (so we don't disrupt current builds) if item.get("current_wielded_location", 0) == 0: score += 5 - + return score - + def _calculate_jewelry_score_bonus(self, items): """ Calculate scoring bonus for jewelry optimization. Phase 3D.3 implementation. """ - jewelry_slots = [ - "Neck", - "Left Ring", - "Right Ring", - "Left Wrist", - "Right Wrist", - "Trinket", - ] + jewelry_slots = ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"] jewelry_items = [item for slot, item in items.items() if slot in jewelry_slots] - + bonus = 0 - + # Slot coverage bonus - 2 points per jewelry slot filled bonus += len(jewelry_items) * 2 - + # Rating bonus from jewelry (jewelry often has high ratings) for item in jewelry_items: - bonus += ( - int(item.get("gear_crit_damage", 0) or 0) * 0.1 - ) # 0.1 points per crit damage point - bonus += ( - int(item.get("gear_damage_rating", 0) or 0) * 0.1 - ) # 0.1 points per damage rating point - bonus += ( - int(item.get("gear_heal_boost", 0) or 0) * 0.05 - ) # 0.05 points per heal boost point - + bonus += int(item.get("gear_crit_damage", 0) or 0) * 0.1 # 0.1 points per crit damage point + bonus += int(item.get("gear_damage_rating", 0) or 0) * 0.1 # 0.1 points per damage rating point + bonus += int(item.get("gear_heal_boost", 0) or 0) * 0.05 # 0.05 points per heal boost point + # Spell diversity bonus from jewelry jewelry_spells = set() for item in jewelry_items: @@ -6772,71 +5652,46 @@ class ConstraintSatisfactionSolver: jewelry_spells.update(item_spells.split(", ")) elif isinstance(item_spells, list): jewelry_spells.update(item_spells) - + # Bonus for legendary spells from jewelry - legendary_count = sum( - 1 for spell in jewelry_spells if "legendary" in spell.lower() - ) + legendary_count = sum(1 for spell in jewelry_spells if "legendary" in spell.lower()) bonus += legendary_count * 3 # 3 points per legendary spell from jewelry - + # Bonus for spell diversity from jewelry - bonus += min( - 5, len(jewelry_spells) * 0.5 - ) # Up to 5 points for jewelry spell diversity - + bonus += min(5, len(jewelry_spells) * 0.5) # Up to 5 points for jewelry spell diversity + return bonus - + def _create_disqualified_suit_result(self, index, items, reason): """Create a result for a disqualified suit (overlapping cantrips)""" # Extract basic info for display primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum( - 1 - for item in items.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int - ) - secondary_count = sum( - 1 - for item in items.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int - ) - + + primary_count = sum(1 for item in items.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int) + secondary_count = sum(1 for item in items.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int) + return { "id": index + 1, "score": 0, # Disqualified suits get 0 score "disqualified": True, "disqualification_reason": reason, - "primary_set": self._get_set_name(self.primary_set) - if self.primary_set - else None, + "primary_set": self._get_set_name(self.primary_set) if self.primary_set else None, "primary_set_count": primary_count, - "secondary_set": self._get_set_name(self.secondary_set) - if self.secondary_set - else None, + "secondary_set": self._get_set_name(self.secondary_set) if self.secondary_set else None, "secondary_set_count": secondary_count, "spell_coverage": 0, - "total_armor": sum( - item.get("armor_level", 0) or 0 for item in items.values() - ), + "total_armor": sum(item.get("armor_level", 0) or 0 for item in items.values()), "stats": { "primary_set_count": primary_count, "secondary_set_count": secondary_count, "required_spells_found": 0, - "total_armor": sum( - item.get("armor_level", 0) or 0 for item in items.values() - ), - "total_crit_damage": sum( - int(item.get("gear_crit_damage", 0) or 0) for item in items.values() - ), - "total_damage_rating": sum( - int(item.get("gear_damage_rating", 0) or 0) - for item in items.values() - ), - "total_heal_boost": sum( - int(item.get("gear_heal_boost", 0) or 0) for item in items.values() - ), + "total_armor": sum(item.get("armor_level", 0) or 0 for item in items.values()), + "total_crit_damage": sum(int(item.get("gear_crit_damage", 0) or 0) for item in items.values()), + "total_damage_rating": sum(int(item.get("gear_damage_rating", 0) or 0) for item in items.values()), + "total_heal_boost": sum(int(item.get("gear_heal_boost", 0) or 0) for item in items.values()) }, "items": { slot: { @@ -6848,106 +5703,80 @@ class ConstraintSatisfactionSolver: "crit_damage_rating": item.get("gear_crit_damage", 0), "damage_rating": item.get("gear_damage_rating", 0), "heal_boost": item.get("gear_heal_boost", 0), - "spell_names": item.get("spell_names", "").split(", ") - if item.get("spell_names") - else [], - "slot_name": item.get("slot_name", slot), + "spell_names": item.get("spell_names", "").split(", ") if item.get("spell_names") else [], + "slot_name": item.get("slot_name", slot) } for slot, item in items.items() - }, + } } - + def _score_suits(self, suits): """Score suits using proper ranking priorities""" scored_suits = [] - + for i, suit in enumerate(suits): items = suit["items"] - + # FIRST: Check for overlapping REQUESTED cantrips (HARD REQUIREMENT) - requested_cantrips = ( - self.legendary_cantrips - ) # Only the cantrips user asked for + requested_cantrips = self.legendary_cantrips # Only the cantrips user asked for all_cantrips = [] cantrip_sources = {} # Track which item has which requested cantrip has_overlap = False overlap_reason = "" - + # DEBUG: Log suit evaluation logger.info(f"Evaluating suit {i}: {len(items)} items") for slot, item in items.items(): - logger.info( - f" {slot}: {item.get('name', 'Unknown')} (set: {item.get('item_set_id', 'None')}) spells: {item.get('spell_names', 'None')}" - ) - + logger.info(f" {slot}: {item.get('name', 'Unknown')} (set: {item.get('item_set_id', 'None')}) spells: {item.get('spell_names', 'None')}") + # Track overlaps but don't disqualify - just note them for scoring penalties overlap_penalty = 0 overlap_notes = [] - + if requested_cantrips: logger.info(f"Checking for overlapping cantrips: {requested_cantrips}") - + # Define ward spells that are allowed to overlap (protection is stackable) ward_spells = { - "legendary flame ward", - "legendary frost ward", - "legendary acid ward", - "legendary storm ward", - "legendary slashing ward", - "legendary piercing ward", - "legendary bludgeoning ward", - "legendary armor", + "legendary flame ward", "legendary frost ward", "legendary acid ward", + "legendary storm ward", "legendary slashing ward", "legendary piercing ward", + "legendary bludgeoning ward", "legendary armor" } - + for slot, item in items.items(): item_spells = item.get("spell_names", []) if isinstance(item_spells, str): item_spells = item_spells.split(", ") - + for spell in item_spells: # Check if this spell is one of the REQUESTED cantrips for requested in requested_cantrips: # More precise matching - check if the requested cantrip is part of the spell name spell_words = spell.lower().split() requested_words = requested.lower().split() - + # Check if all words in the requested cantrip appear in the spell - matches_all_words = all( - any( - req_word in spell_word for spell_word in spell_words - ) - for req_word in requested_words - ) - + matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) + for req_word in requested_words) + if matches_all_words: - logger.info( - f" Found match: '{requested}' matches '{spell}' on {slot}" - ) - + logger.info(f" Found match: '{requested}' matches '{spell}' on {slot}") + # Check if this is a ward spell (protection) - these are allowed to overlap is_ward_spell = requested.lower() in ward_spells - - if ( - requested in cantrip_sources - and cantrip_sources[requested] != slot - ): + + if requested in cantrip_sources and cantrip_sources[requested] != slot: if is_ward_spell: # Ward spells are allowed to overlap - no penalty - logger.info( - f" Ward overlap allowed: {requested} on {cantrip_sources[requested]} and {slot}" - ) + logger.info(f" Ward overlap allowed: {requested} on {cantrip_sources[requested]} and {slot}") else: # Non-ward spells overlapping - apply penalty but don't disqualify - overlap_penalty += ( - 50 # 50 point penalty per overlap - ) + overlap_penalty += 50 # 50 point penalty per overlap overlap_note = f"Overlapping {requested} on {cantrip_sources[requested]} and {slot}" overlap_notes.append(overlap_note) - logger.warning( - f" OVERLAP PENALTY: {overlap_note}" - ) + logger.warning(f" OVERLAP PENALTY: {overlap_note}") cantrip_sources[requested] = slot - + # Also track all legendary cantrips for bonus scoring if "legendary" in spell.lower(): all_cantrips.append(spell) @@ -6957,72 +5786,54 @@ class ConstraintSatisfactionSolver: item_spells = item.get("spell_names", []) if isinstance(item_spells, str): item_spells = item_spells.split(", ") - + for spell in item_spells: if "legendary" in spell.lower(): all_cantrips.append(spell) - + # Proceed with scoring (including any overlap penalties) score = 0 - + # Count set pieces primary_set_int = int(self.primary_set) if self.primary_set else None secondary_set_int = int(self.secondary_set) if self.secondary_set else None - - primary_count = sum( - 1 - for item in items.values() - if int(item.get("item_set_id", 0) or 0) == primary_set_int - ) - secondary_count = sum( - 1 - for item in items.values() - if int(item.get("item_set_id", 0) or 0) == secondary_set_int - ) - + + primary_count = sum(1 for item in items.values() + if int(item.get("item_set_id", 0) or 0) == primary_set_int) + secondary_count = sum(1 for item in items.values() + if int(item.get("item_set_id", 0) or 0) == secondary_set_int) + # PRIORITY 1: Equipment Set Completion (300-500 points) - only if sets requested if self.primary_set: if primary_count >= 5: score += 300 # Full primary set else: score += primary_count * 50 # 50 points per piece - + if self.secondary_set: if secondary_count >= 4: score += 200 # Full secondary set else: score += secondary_count * 40 # 40 points per piece - + # Calculate total ratings - total_armor = sum( - item.get("armor_level", 0) or 0 for item in items.values() - ) - total_crit_damage = sum( - int(item.get("gear_crit_damage", 0) or 0) for item in items.values() - ) - total_damage_rating = sum( - int(item.get("gear_damage_rating", 0) or 0) for item in items.values() - ) - total_heal_boost = sum( - int(item.get("gear_heal_boost", 0) or 0) for item in items.values() - ) - + total_armor = sum(item.get("armor_level", 0) or 0 for item in items.values()) + total_crit_damage = sum(int(item.get("gear_crit_damage", 0) or 0) for item in items.values()) + total_damage_rating = sum(int(item.get("gear_damage_rating", 0) or 0) for item in items.values()) + total_heal_boost = sum(int(item.get("gear_heal_boost", 0) or 0) for item in items.values()) + # PRIORITY 2: Crit Damage Rating (10 points per point) - only if requested if self.min_crit_damage > 0: score += total_crit_damage * 10 else: - score += ( - total_crit_damage * 1 - ) # Minor bonus if not specifically requested - + score += total_crit_damage * 1 # Minor bonus if not specifically requested + # PRIORITY 3: Damage Rating (8 points per point) - only if requested if self.min_damage_rating > 0: score += total_damage_rating * 8 else: - score += ( - total_damage_rating * 1 - ) # Minor bonus if not specifically requested - + score += total_damage_rating * 1 # Minor bonus if not specifically requested + # BONUS: Required spell coverage (up to 50 points) all_spells = set() for item in items.values(): @@ -7032,7 +5843,7 @@ class ConstraintSatisfactionSolver: all_spells.update(item_spells.split(", ")) else: all_spells.update(item_spells) - + required_spells = self.legendary_cantrips + self.protection_spells if required_spells: # Use fuzzy matching for spell coverage like we do for overlap detection @@ -7042,107 +5853,78 @@ class ConstraintSatisfactionSolver: # More precise matching - check if the requested cantrip is part of the spell name spell_words = actual_spell.lower().split() required_words = required.lower().split() - + # Check if all words in the requested cantrip appear in the spell - matches_all_words = all( - any(req_word in spell_word for spell_word in spell_words) - for req_word in required_words - ) - + matches_all_words = all(any(req_word in spell_word for spell_word in spell_words) + for req_word in required_words) + if matches_all_words: spell_coverage_count += 1 break # Found this required spell, move to next - - logger.info( - f"Spell coverage: {spell_coverage_count}/{len(required_spells)} required spells found" - ) + + logger.info(f"Spell coverage: {spell_coverage_count}/{len(required_spells)} required spells found") score += (spell_coverage_count / len(required_spells)) * 50 - + # BONUS: Total unique cantrips (2 points each) score += len(all_cantrips) * 2 - + # BONUS: Total armor level (only if armor minimum requested) if self.min_armor > 0: score += total_armor * 0.1 # Higher bonus if specifically requested else: score += total_armor * 0.01 # Minor bonus for general armor - + # BONUS: Meeting minimum requirements (10 points each) armor_req_met = self.min_armor > 0 and total_armor >= self.min_armor - crit_req_met = ( - self.min_crit_damage > 0 and total_crit_damage >= self.min_crit_damage - ) - damage_req_met = ( - self.min_damage_rating > 0 - and total_damage_rating >= self.min_damage_rating - ) - + crit_req_met = self.min_crit_damage > 0 and total_crit_damage >= self.min_crit_damage + damage_req_met = self.min_damage_rating > 0 and total_damage_rating >= self.min_damage_rating + if armor_req_met: score += 10 if crit_req_met: score += 10 if damage_req_met: score += 10 - + # Apply overlap penalty score -= overlap_penalty - + # CRITICAL: Heavy penalty for not meeting required minimums if self.min_armor > 0 and total_armor < self.min_armor: score -= 200 # Heavy penalty for not meeting armor requirement if self.min_crit_damage > 0 and total_crit_damage < self.min_crit_damage: - score -= 200 # Heavy penalty for not meeting crit requirement - if ( - self.min_damage_rating > 0 - and total_damage_rating < self.min_damage_rating - ): + score -= 200 # Heavy penalty for not meeting crit requirement + if self.min_damage_rating > 0 and total_damage_rating < self.min_damage_rating: score -= 200 # Heavy penalty for not meeting damage requirement if self.min_heal_boost > 0 and total_heal_boost < self.min_heal_boost: score -= 200 # Heavy penalty for not meeting heal requirement - + # CRITICAL: Heavy penalty for not getting required set counts if self.primary_set and primary_count < 5: - score -= ( - 5 - primary_count - ) * 30 # 30 point penalty per missing primary set piece + score -= (5 - primary_count) * 30 # 30 point penalty per missing primary set piece if self.secondary_set and secondary_count < 4: - score -= ( - 4 - secondary_count - ) * 25 # 25 point penalty per missing secondary set piece - - logger.info( - f"Suit {i} final score: {int(score)} (primary: {primary_count}, secondary: {secondary_count}, armor: {total_armor}, crit: {total_crit_damage}, damage: {total_damage_rating}, overlap_penalty: {overlap_penalty})" - ) - + score -= (4 - secondary_count) * 25 # 25 point penalty per missing secondary set piece + + logger.info(f"Suit {i} final score: {int(score)} (primary: {primary_count}, secondary: {secondary_count}, armor: {total_armor}, crit: {total_crit_damage}, damage: {total_damage_rating}, overlap_penalty: {overlap_penalty})") + # Create suit result suit_result = { "id": i + 1, "score": int(score), - "primary_set": self._get_set_name(self.primary_set) - if self.primary_set - else None, + "primary_set": self._get_set_name(self.primary_set) if self.primary_set else None, "primary_set_count": primary_count, - "secondary_set": self._get_set_name(self.secondary_set) - if self.secondary_set - else None, + "secondary_set": self._get_set_name(self.secondary_set) if self.secondary_set else None, "secondary_set_count": secondary_count, "spell_coverage": len(all_spells), "total_armor": total_armor, "stats": { "primary_set_count": primary_count, "secondary_set_count": secondary_count, - "required_spells_found": len( - [ - spell - for spell in self.legendary_cantrips - + self.protection_spells - if spell in all_spells - ] - ), + "required_spells_found": len([spell for spell in self.legendary_cantrips + self.protection_spells if spell in all_spells]), "total_armor": total_armor, "total_crit_damage": total_crit_damage, "total_damage_rating": total_damage_rating, - "total_heal_boost": total_heal_boost, + "total_heal_boost": total_heal_boost }, "items": { slot: { @@ -7154,61 +5936,51 @@ class ConstraintSatisfactionSolver: "crit_damage_rating": item.get("gear_crit_damage", 0), "damage_rating": item.get("gear_damage_rating", 0), "heal_boost": item.get("gear_heal_boost", 0), - "spell_names": item.get("spell_names", "").split(", ") - if item.get("spell_names") - else [], - "slot_name": item.get("slot_name", slot), + "spell_names": item.get("spell_names", "").split(", ") if item.get("spell_names") else [], + "slot_name": item.get("slot_name", slot) } for slot, item in items.items() }, - "notes": overlap_notes if overlap_notes else [], + "notes": overlap_notes if overlap_notes else [] } - + # Add comprehensive constraint analysis - add_suit_analysis( - suit_result, - self.primary_set, - self.secondary_set, - self.legendary_cantrips + self.protection_spells, - self.min_armor, - self.min_crit_damage, - self.min_damage_rating, - self.min_heal_boost, - ) - + add_suit_analysis(suit_result, self.primary_set, self.secondary_set, + self.legendary_cantrips + self.protection_spells, + self.min_armor, self.min_crit_damage, self.min_damage_rating, self.min_heal_boost) + scored_suits.append(suit_result) - + # Sort by score descending scored_suits.sort(key=lambda x: x["score"], reverse=True) return scored_suits - + def _get_set_name(self, set_id): """Get human-readable set name from set ID""" if not set_id: return "No Set" - + set_id_str = str(set_id) - dictionaries = ENUM_MAPPINGS.get("dictionaries", {}) - attribute_set_info = dictionaries.get("AttributeSetInfo", {}).get("values", {}) - + dictionaries = ENUM_MAPPINGS.get('dictionaries', {}) + attribute_set_info = dictionaries.get('AttributeSetInfo', {}).get('values', {}) + if set_id_str in attribute_set_info: return attribute_set_info[set_id_str] else: return f"Unknown Set ({set_id})" - + + def solve(self, max_results, max_iterations=1000): """ Solve the constraint satisfaction problem using iterative search. - + Returns the top solutions found within the iteration limit. """ - logger.info( - f"CSP Solver: Starting search with {len(self.candidate_items)} items, {max_iterations} max iterations" - ) - + logger.info(f"CSP Solver: Starting search with {len(self.candidate_items)} items, {max_iterations} max iterations") + solutions = [] iteration = 0 - + # Strategy 1: Set-First Greedy Search (50% of iterations) for i in range(max_iterations // 2): solution = self._solve_set_first_greedy() @@ -7217,7 +5989,7 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: # Get extra solutions for ranking break iteration += 1 - + # Strategy 2: Backtracking Search (30% of iterations) for i in range(max_iterations * 3 // 10): solution = self._solve_backtracking() @@ -7226,7 +5998,7 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: break iteration += 1 - + # Strategy 3: Random Restarts (20% of iterations) for i in range(max_iterations // 5): solution = self._solve_random_restart() @@ -7235,246 +6007,201 @@ class ConstraintSatisfactionSolver: if len(solutions) >= max_results * 2: break iteration += 1 - - logger.info( - f"CSP Solver: Found {len(solutions)} solutions in {iteration} iterations" - ) - + + logger.info(f"CSP Solver: Found {len(solutions)} solutions in {iteration} iterations") + # Score and rank all solutions for solution in solutions: self._calculate_solution_stats(solution) solution["score"] = self._calculate_solution_score(solution) solution["id"] = solutions.index(solution) + 1 self._add_solution_analysis(solution) - + # Return top solutions solutions.sort(key=lambda x: x["score"], reverse=True) return solutions[:max_results] - + def _solve_set_first_greedy(self): """ Greedy algorithm that prioritizes set requirements first. """ solution = {"items": {}, "stats": {}} used_items = set() - + # Phase 1: Place primary set items primary_placed = 0 if self.primary_set and self.primary_set in self.items_by_set: - primary_items = sorted( - self.items_by_set[self.primary_set], - key=lambda x: self._item_value(x, solution), - reverse=True, - ) - + primary_items = sorted(self.items_by_set[self.primary_set], + key=lambda x: self._item_value(x, solution), reverse=True) + for item in primary_items: if primary_placed >= 5: # Only need 5 for primary set break if item["item_id"] in used_items: continue - + # Find best slot for this item possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot( - possible_slots, solution["items"] - ) - + best_slot = self._find_best_available_slot(possible_slots, solution["items"]) + if best_slot: solution["items"][best_slot] = item used_items.add(item["item_id"]) primary_placed += 1 - + # Phase 2: Place secondary set items secondary_placed = 0 if self.secondary_set and self.secondary_set in self.items_by_set: - secondary_items = sorted( - self.items_by_set[self.secondary_set], - key=lambda x: self._item_value(x, solution), - reverse=True, - ) - + secondary_items = sorted(self.items_by_set[self.secondary_set], + key=lambda x: self._item_value(x, solution), reverse=True) + for item in secondary_items: if secondary_placed >= 4: # Only need 4 for secondary set break if item["item_id"] in used_items: continue - + possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot( - possible_slots, solution["items"] - ) - + best_slot = self._find_best_available_slot(possible_slots, solution["items"]) + if best_slot: solution["items"][best_slot] = item used_items.add(item["item_id"]) secondary_placed += 1 - + # Phase 3: Place items with required spells for spell in self.required_spells: if spell in self.items_with_spells: - spell_items = sorted( - self.items_with_spells[spell], - key=lambda x: self._item_value(x), - reverse=True, - ) - + spell_items = sorted(self.items_with_spells[spell], + key=lambda x: self._item_value(x), reverse=True) + # Try to place at least one item with this spell placed_spell = False for item in spell_items: if item["item_id"] in used_items: continue - + possible_slots = self._get_item_slots(item) - best_slot = self._find_best_available_slot( - possible_slots, solution["items"] - ) - + best_slot = self._find_best_available_slot(possible_slots, solution["items"]) + if best_slot: # Check if replacing existing item is beneficial existing_item = solution["items"].get(best_slot) - if existing_item is None or self._should_replace_item( - existing_item, item - ): + if existing_item is None or self._should_replace_item(existing_item, item): if existing_item: used_items.discard(existing_item["item_id"]) solution["items"][best_slot] = item used_items.add(item["item_id"]) placed_spell = True break - + # Phase 4: Optimally fill remaining slots to maximize set bonuses self._optimize_remaining_slots(solution, used_items) - + return solution if solution["items"] else None - + def _optimize_remaining_slots(self, solution, used_items): """Optimally fill remaining slots to maximize constraint satisfaction.""" # Calculate current set counts - primary_count = sum( - 1 - for item in solution["items"].values() - if item.get("item_set_id") == self.primary_set - ) - secondary_count = sum( - 1 - for item in solution["items"].values() - if item.get("item_set_id") == self.secondary_set - ) - + primary_count = sum(1 for item in solution["items"].values() + if item.get("item_set_id") == self.primary_set) + secondary_count = sum(1 for item in solution["items"].values() + if item.get("item_set_id") == self.secondary_set) + # Fill slots prioritizing most needed sets for slot in self.all_slots: if slot in solution["items"]: continue # Already filled - + if slot not in self.items_by_slot: continue - - available_items = [ - item - for item in self.items_by_slot[slot] - if item["item_id"] not in used_items - ] - + + available_items = [item for item in self.items_by_slot[slot] + if item["item_id"] not in used_items] + if not available_items: continue - + # Find best item for this slot based on current needs best_item = None best_value = -1 - + for item in available_items: item_value = self._item_value(item, solution) - + # Bonus for items that help with our priority sets item_set = item.get("item_set_id") - if ( - self.primary_set - and item_set == self.primary_set - and primary_count < 5 - ): + if self.primary_set and item_set == self.primary_set and primary_count < 5: item_value += 2000 # Very high bonus for needed primary pieces - elif ( - self.secondary_set - and item_set == self.secondary_set - and secondary_count < 4 - ): + elif self.secondary_set and item_set == self.secondary_set and secondary_count < 4: item_value += 1500 # High bonus for needed secondary pieces - + if item_value > best_value: best_value = item_value best_item = item - + if best_item: solution["items"][slot] = best_item used_items.add(best_item["item_id"]) - + # Update counts for next iteration if best_item.get("item_set_id") == self.primary_set: primary_count += 1 elif best_item.get("item_set_id") == self.secondary_set: secondary_count += 1 - + def _solve_backtracking(self): """ Backtracking algorithm that explores solution space systematically. """ solution = {"items": {}, "stats": {}} used_items = set() - + # Create ordered list of (slot, constraints) for systematic search slot_constraints = self._create_slot_constraints() - + # Attempt backtracking search if self._backtrack_search(solution, used_items, slot_constraints, 0): return solution return None - + def _solve_random_restart(self): """ Random restart algorithm for exploring different parts of solution space. """ import random - + solution = {"items": {}, "stats": {}} used_items = set() - + # Randomly order slots and items for different exploration paths random_slots = self.all_slots.copy() random.shuffle(random_slots) - + for slot in random_slots: if slot not in self.items_by_slot: continue - - available_items = [ - item - for item in self.items_by_slot[slot] - if item["item_id"] not in used_items - ] - + + available_items = [item for item in self.items_by_slot[slot] + if item["item_id"] not in used_items] + if available_items: # Add some randomness to item selection while still preferring better items - weights = [ - self._item_value(item) + random.randint(0, 100) - for item in available_items - ] + weights = [self._item_value(item) + random.randint(0, 100) for item in available_items] max_weight = max(weights) - best_items = [ - item - for i, item in enumerate(available_items) - if weights[i] >= max_weight * 0.8 - ] # Top 20% with randomness - + best_items = [item for i, item in enumerate(available_items) + if weights[i] >= max_weight * 0.8] # Top 20% with randomness + selected_item = random.choice(best_items) solution["items"][slot] = selected_item used_items.add(selected_item["item_id"]) - + return solution if solution["items"] else None - + def _item_value(self, item, current_solution=None): """Calculate the value/priority of an item for constraint satisfaction.""" value = 0 - + # Get current set counts if solution provided primary_count = 0 secondary_count = 0 @@ -7485,164 +6212,126 @@ class ConstraintSatisfactionSolver: primary_count += 1 if self.secondary_set and existing_set == self.secondary_set: secondary_count += 1 - + # Dynamic set bonus value based on current needs item_set = item.get("item_set_id") if self.primary_set and item_set == self.primary_set: # Primary set priority decreases as we get closer to 5 pieces if primary_count < 5: - value += ( - 1000 + (5 - primary_count) * 100 - ) # Higher priority when we need more + value += 1000 + (5 - primary_count) * 100 # Higher priority when we need more else: value += 500 # Lower priority when we have enough - + if self.secondary_set and item_set == self.secondary_set: # Secondary set priority increases when primary is satisfied if secondary_count < 4: - if ( - primary_count >= 4 - ): # If primary is mostly satisfied, prioritize secondary + if primary_count >= 4: # If primary is mostly satisfied, prioritize secondary value += 1200 + (4 - secondary_count) * 150 # Very high priority else: - value += 800 + (4 - secondary_count) * 100 # High priority + value += 800 + (4 - secondary_count) * 100 # High priority else: value += 400 # Lower priority when we have enough - + # Spell bonus value item_spells = item.get("spell_names", []) for spell in self.required_spells: if spell in item_spells: value += 500 # High priority for required spells - + # Rating value value += item.get("armor_level", 0) value += item.get("crit_damage_rating", 0) * 10 value += item.get("damage_rating", 0) * 10 - + return value - + def _get_item_slots(self, item): """Get list of slots this item can be equipped to.""" return determine_item_slots(item) - + def _find_best_available_slot(self, possible_slots, current_items): """Find the best available slot from possible slots.""" for slot in possible_slots: if slot not in current_items: return slot return None - + def _should_replace_item(self, existing_item, new_item): """Determine if new item should replace existing item.""" existing_value = self._item_value(existing_item) new_value = self._item_value(new_item) return new_value > existing_value * 1.2 # 20% better to replace - + def _create_slot_constraints(self): """Create ordered list of slot constraints for backtracking.""" # This is a simplified version - full implementation would be more sophisticated return [(slot, []) for slot in self.all_slots] - + def _backtrack_search(self, solution, used_items, slot_constraints, slot_index): """Recursive backtracking search.""" if slot_index >= len(slot_constraints): return True # Found complete solution - + slot, constraints = slot_constraints[slot_index] - + if slot not in self.items_by_slot: - return self._backtrack_search( - solution, used_items, slot_constraints, slot_index + 1 - ) - + return self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1) + # Try each item for this slot - available_items = [ - item - for item in self.items_by_slot[slot] - if item["item_id"] not in used_items - ] - + available_items = [item for item in self.items_by_slot[slot] + if item["item_id"] not in used_items] + # Sort by value for better pruning available_items.sort(key=lambda x: self._item_value(x), reverse=True) - + for item in available_items[:5]: # Limit search to top 5 items per slot # Try placing this item solution["items"][slot] = item used_items.add(item["item_id"]) - + # Recursive search - if self._backtrack_search( - solution, used_items, slot_constraints, slot_index + 1 - ): + if self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1): return True - + # Backtrack del solution["items"][slot] used_items.remove(item["item_id"]) - + # Try leaving slot empty - return self._backtrack_search( - solution, used_items, slot_constraints, slot_index + 1 - ) - + return self._backtrack_search(solution, used_items, slot_constraints, slot_index + 1) + def _is_unique_solution(self, solution, existing_solutions): """Check if solution is substantially different from existing ones.""" if not existing_solutions: return True - + solution_items = set(item["item_id"] for item in solution["items"].values()) - + for existing in existing_solutions: existing_items = set(item["item_id"] for item in existing["items"].values()) overlap = len(solution_items & existing_items) / max(len(solution_items), 1) if overlap > 0.7: # 70% overlap = too similar return False - + return True - + def _calculate_solution_stats(self, solution): """Calculate comprehensive statistics for solution.""" - calculate_suit_stats( - solution, self.primary_set, self.secondary_set, self.required_spells - ) - + calculate_suit_stats(solution, self.primary_set, self.secondary_set, self.required_spells) + def _calculate_solution_score(self, solution): """Calculate constraint satisfaction score for solution.""" - return calculate_suit_score( - solution, - self.primary_set, - self.secondary_set, - self.required_spells, - self.min_armor, - self.min_crit_damage, - self.min_damage_rating, - ) - + return calculate_suit_score(solution, self.primary_set, self.secondary_set, self.required_spells, + self.min_armor, self.min_crit_damage, self.min_damage_rating) + def _add_solution_analysis(self, solution): """Add analysis of what's missing or achieved.""" - add_suit_analysis( - solution, - self.primary_set, - self.secondary_set, - self.required_spells, - self.min_armor, - self.min_crit_damage, - self.min_damage_rating, - self.min_heal_boost, - ) + add_suit_analysis(solution, self.primary_set, self.secondary_set, self.required_spells, + self.min_armor, self.min_crit_damage, self.min_damage_rating, self.min_heal_boost) -def generate_optimal_suits( - items_by_slot, - primary_set, - secondary_set, - required_spells, - min_armor, - min_crit_damage, - min_damage_rating, - max_results, -): +def generate_optimal_suits(items_by_slot, primary_set, secondary_set, required_spells, + min_armor, min_crit_damage, min_damage_rating, max_results): """ Generate optimal equipment suit combinations using iterative constraint satisfaction. """ @@ -7650,44 +6339,30 @@ def generate_optimal_suits( all_candidate_items = [] for slot_items in items_by_slot.values(): all_candidate_items.extend(slot_items) - + # Remove duplicates (same item might be valid for multiple slots) unique_items = {item["item_id"]: item for item in all_candidate_items} candidate_items = list(unique_items.values()) - + # Initialize constraint satisfaction solver - solver = ConstraintSatisfactionSolver( - candidate_items, - items_by_slot, - primary_set, - secondary_set, - required_spells, - min_armor, - min_crit_damage, - min_damage_rating, - ) - + solver = ConstraintSatisfactionSolver(candidate_items, items_by_slot, + primary_set, secondary_set, required_spells, + min_armor, min_crit_damage, min_damage_rating) + # Generate solutions using iterative constraint satisfaction suits = solver.solve(max_results, max_iterations=1000) - + return suits -def calculate_suit_score( - suit, - primary_set, - secondary_set, - required_spells, - min_armor, - min_crit_damage, - min_damage_rating, -): +def calculate_suit_score(suit, primary_set, secondary_set, required_spells, + min_armor, min_crit_damage, min_damage_rating): """ Calculate a score for how well a suit satisfies the constraints. """ score = 0 stats = suit["stats"] - + # Set bonus scoring (most important) if primary_set: primary_target = 5 @@ -7696,7 +6371,7 @@ def calculate_suit_score( score += 40 # Full primary set bonus else: score += (primary_actual / primary_target) * 30 # Partial credit - + if secondary_set: secondary_target = 4 secondary_actual = stats["secondary_set_count"] @@ -7704,12 +6379,12 @@ def calculate_suit_score( score += 30 # Full secondary set bonus else: score += (secondary_actual / secondary_target) * 20 # Partial credit - + # Required spells scoring if required_spells: spell_ratio = min(1.0, stats["required_spells_found"] / len(required_spells)) score += spell_ratio * 20 - + # Rating requirements scoring if min_armor and stats["total_armor"] >= min_armor: score += 5 @@ -7717,47 +6392,28 @@ def calculate_suit_score( score += 5 if min_damage_rating and stats["total_damage_rating"] >= min_damage_rating: score += 5 - + return int(score) -def add_suit_analysis( - suit, - primary_set, - secondary_set, - required_spells, - min_armor=0, - min_crit_damage=0, - min_damage_rating=0, - min_heal_boost=0, -): +def add_suit_analysis(suit, primary_set, secondary_set, required_spells, + min_armor=0, min_crit_damage=0, min_damage_rating=0, min_heal_boost=0): """ Add comprehensive analysis of missing constraints and achievements in the suit. """ stats = suit["stats"] missing = [] notes = [] - + # Get set names for display set_names = { - 13: "Soldier's Set", - 14: "Adept's Set", - 16: "Defender's Set", - 21: "Wise Set", - 40: "Heroic Protector", - 41: "Heroic Destroyer", - 46: "Relic Alduressa", - 47: "Ancient Relic", - 48: "Noble Relic", - 15: "Archer's Set", - 19: "Hearty Set", - 20: "Dexterous Set", - 22: "Swift Set", - 24: "Reinforced Set", - 26: "Flame Proof Set", - 29: "Lightning Proof Set", + 13: "Soldier's Set", 14: "Adept's Set", 16: "Defender's Set", 21: "Wise Set", + 40: "Heroic Protector", 41: "Heroic Destroyer", 46: "Relic Alduressa", + 47: "Ancient Relic", 48: "Noble Relic", 15: "Archer's Set", 19: "Hearty Set", + 20: "Dexterous Set", 22: "Swift Set", 24: "Reinforced Set", 26: "Flame Proof Set", + 29: "Lightning Proof Set" } - + # Check primary set requirements if primary_set: primary_set_int = int(primary_set) @@ -7767,7 +6423,7 @@ def add_suit_analysis( missing.append(f"Need {needed} more {set_name} pieces") else: notes.append(f"✅ {set_name} (5/5)") - + # Check secondary set requirements if secondary_set: secondary_set_int = int(secondary_set) @@ -7777,7 +6433,7 @@ def add_suit_analysis( missing.append(f"Need {needed} more {set_name} pieces") else: notes.append(f"✅ {set_name} (4/4)") - + # Check legendary cantrips/spells requirements if required_spells: found = stats.get("required_spells_found", 0) @@ -7785,39 +6441,32 @@ def add_suit_analysis( if found < total: missing_count = total - found missing_spells = [] - + # Determine which specific spells are missing suit_spells = set() for item in suit["items"].values(): if "spell_names" in item and item["spell_names"]: if isinstance(item["spell_names"], str): - suit_spells.update( - spell.strip() for spell in item["spell_names"].split(",") - ) + suit_spells.update(spell.strip() for spell in item["spell_names"].split(",")) elif isinstance(item["spell_names"], list): suit_spells.update(item["spell_names"]) - + for req_spell in required_spells: found_match = False for suit_spell in suit_spells: - if ( - req_spell.lower() in suit_spell.lower() - or suit_spell.lower() in req_spell.lower() - ): + if req_spell.lower() in suit_spell.lower() or suit_spell.lower() in req_spell.lower(): found_match = True break if not found_match: missing_spells.append(req_spell) - + if missing_spells: - missing.append( - f"Missing: {', '.join(missing_spells[:3])}{'...' if len(missing_spells) > 3 else ''}" - ) + missing.append(f"Missing: {', '.join(missing_spells[:3])}{'...' if len(missing_spells) > 3 else ''}") else: missing.append(f"Need {missing_count} more required spells") else: notes.append(f"✅ All {total} required spells found") - + # Check armor level requirements if min_armor > 0: current_armor = stats.get("total_armor", 0) @@ -7826,7 +6475,7 @@ def add_suit_analysis( missing.append(f"Armor: {current_armor}/{min_armor} (-{shortfall})") else: notes.append(f"✅ Armor: {current_armor} (≥{min_armor})") - + # Check crit damage rating requirements if min_crit_damage > 0: current_crit = stats.get("total_crit_damage", 0) @@ -7835,77 +6484,50 @@ def add_suit_analysis( missing.append(f"Crit Dmg: {current_crit}/{min_crit_damage} (-{shortfall})") else: notes.append(f"✅ Crit Dmg: {current_crit} (≥{min_crit_damage})") - + # Check damage rating requirements if min_damage_rating > 0: current_dmg = stats.get("total_damage_rating", 0) if current_dmg < min_damage_rating: shortfall = min_damage_rating - current_dmg - missing.append( - f"Dmg Rating: {current_dmg}/{min_damage_rating} (-{shortfall})" - ) + missing.append(f"Dmg Rating: {current_dmg}/{min_damage_rating} (-{shortfall})") else: notes.append(f"✅ Dmg Rating: {current_dmg} (≥{min_damage_rating})") - + # Check heal boost requirements if min_heal_boost > 0: current_heal = stats.get("total_heal_boost", 0) if current_heal < min_heal_boost: shortfall = min_heal_boost - current_heal - missing.append( - f"Heal Boost: {current_heal}/{min_heal_boost} (-{shortfall})" - ) + missing.append(f"Heal Boost: {current_heal}/{min_heal_boost} (-{shortfall})") else: notes.append(f"✅ Heal Boost: {current_heal} (≥{min_heal_boost})") - + # Add slot coverage analysis - armor_slots_filled = sum( - 1 - for slot in [ - "Head", - "Chest", - "Upper Arms", - "Lower Arms", - "Hands", - "Abdomen", - "Upper Legs", - "Lower Legs", - "Feet", - ] - if slot in suit["items"] - ) - jewelry_slots_filled = sum( - 1 - for slot in [ - "Neck", - "Left Ring", - "Right Ring", - "Left Wrist", - "Right Wrist", - "Trinket", - ] - if slot in suit["items"] - ) - clothing_slots_filled = sum( - 1 for slot in ["Shirt", "Pants"] if slot in suit["items"] - ) - + armor_slots_filled = sum(1 for slot in ["Head", "Chest", "Upper Arms", "Lower Arms", "Hands", + "Abdomen", "Upper Legs", "Lower Legs", "Feet"] + if slot in suit["items"]) + jewelry_slots_filled = sum(1 for slot in ["Neck", "Left Ring", "Right Ring", + "Left Wrist", "Right Wrist", "Trinket"] + if slot in suit["items"]) + clothing_slots_filled = sum(1 for slot in ["Shirt", "Pants"] + if slot in suit["items"]) + if armor_slots_filled < 9: missing.append(f"{9 - armor_slots_filled} armor slots empty") else: notes.append("✅ All 9 armor slots filled") - + if jewelry_slots_filled > 0: notes.append(f"📿 {jewelry_slots_filled}/6 jewelry slots filled") - + if clothing_slots_filled > 0: notes.append(f"👕 {clothing_slots_filled}/2 clothing slots filled") - + suit["missing"] = missing suit["notes"] = notes if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/main.py b/main.py index 48e108c0..146e2a00 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,6 @@ This service ingests real-time position and event data from plugin clients via W stores telemetry and statistics in a TimescaleDB backend, and exposes HTTP and WebSocket endpoints for browser clients to retrieve live and historical data, trails, and per-character stats. """ - from datetime import datetime, timedelta, timezone import json import logging @@ -18,15 +17,7 @@ import asyncio import socket import struct -from fastapi import ( - FastAPI, - Header, - HTTPException, - Query, - WebSocket, - WebSocketDisconnect, - Request, -) +from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles @@ -51,28 +42,26 @@ from db_async import ( server_health_checks, server_status, init_db_async, - cleanup_old_portals, + cleanup_old_portals ) import asyncio # Configure logging logging.basicConfig( level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(sys.stdout), - ], + ] ) logger = logging.getLogger(__name__) # Get log level from environment (DEBUG, INFO, WARNING, ERROR) -log_level = os.getenv("LOG_LEVEL", "INFO").upper() +log_level = os.getenv('LOG_LEVEL', 'INFO').upper() logger.setLevel(getattr(logging, log_level, logging.INFO)) # Inventory service configuration -INVENTORY_SERVICE_URL = os.getenv( - "INVENTORY_SERVICE_URL", "http://inventory-service:8000" -) +INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') # In-memory caches for REST endpoints _cached_live: dict = {"players": []} _cached_trails: dict = {"trails": []} @@ -81,17 +70,16 @@ _cached_total_kills: dict = {"total": 0, "last_updated": None} _cache_task: asyncio.Task | None = None _rares_cache_task: asyncio.Task | None = None _cleanup_task: asyncio.Task | None = None -_broadcast_tasks: set[asyncio.Task] = set() # Player tracking for debug purposes _player_history: list = [] # List of player sets from last 10 refreshes -_player_events: list = [] # List of player enter/exit events -_max_history_size = 10 # Keep last 10 player sets -_max_events_size = 100 # Keep last 100 events +_player_events: list = [] # List of player enter/exit events +_max_history_size = 10 # Keep last 10 player sets +_max_events_size = 100 # Keep last 100 events # Telemetry timing tracking for debug purposes _player_telemetry_times: dict = {} # character_name -> list of timestamps -_max_telemetry_history = 20 # Keep last 20 telemetry timestamps per player +_max_telemetry_history = 20 # Keep last 20 telemetry timestamps per player # Simple WebSocket connection counters (Phase 1) _plugin_connections = 0 @@ -113,25 +101,24 @@ _server_status_cache = { "player_count": None, "last_check": None, "uptime_seconds": 0, - "last_restart": None, + "last_restart": None } # Quest status cache - stores last received quest data per player # Structure: {character_name: {quest_name: countdown_value}} _quest_status_cache: Dict[str, Dict[str, str]] = {} - # AC Hash32 checksum algorithm (based on ThwargLauncher) def calculate_hash32(data: bytes) -> int: """Calculate AC Hash32 checksum as used in ThwargLauncher.""" length = len(data) checksum = (length << 16) & 0xFFFFFFFF - + # Process 4-byte chunks for i in range(0, length - 3, 4): - chunk = struct.unpack(" int: byte_val = data[i] << shift checksum = (checksum + byte_val) & 0xFFFFFFFF shift -= 8 - + return checksum - # Create AC EchoRequest packet for server health check (based on ThwargLauncher) def create_echo_request_packet(): """Create an AC EchoRequest packet for server health checking.""" # AC packet header: sequence(4) + flags(4) + checksum(4) + id(2) + time(2) + size(2) + table(2) = 20 bytes + padding packet = bytearray(32) # 32 bytes total (0x20) - + # Sequence (4 bytes) - can be 0 - struct.pack_into(" tuple[bool, float, int]: +async def check_server_health(address: str, port: int, timeout: float = 3.0) -> tuple[bool, float, int]: """Check AC server health via UDP packet with retry logic. - + Retries 6 times with 5-second delays before declaring server down. Returns: (is_up, latency_ms, player_count) """ max_retries = 6 retry_delay = 5.0 - + for attempt in range(max_retries): - logger.debug( - f"🔍 Health check attempt {attempt + 1}/{max_retries} for {address}:{port}" - ) + logger.debug(f"🔍 Health check attempt {attempt + 1}/{max_retries} for {address}:{port}") start_time = time.time() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setblocking(False) - + try: # Send login packet (same as ThwargLauncher) - await asyncio.get_event_loop().sock_sendto( - sock, AC_LOGIN_PACKET, (address, port) - ) - + await asyncio.get_event_loop().sock_sendto(sock, AC_LOGIN_PACKET, (address, port)) + # Wait for response with timeout try: data, addr = await asyncio.wait_for( - asyncio.get_event_loop().sock_recvfrom(sock, 1024), timeout=timeout + asyncio.get_event_loop().sock_recvfrom(sock, 1024), + timeout=timeout ) - + latency_ms = (time.time() - start_time) * 1000 - logger.debug( - f"📥 Received response from {addr}: {len(data)} bytes, latency: {latency_ms:.1f}ms" - ) - + logger.debug(f"📥 Received response from {addr}: {len(data)} bytes, latency: {latency_ms:.1f}ms") + # Check if valid response (support both TimeSynch 0x800000 and ConnectRequest 0x40000) if len(data) >= 24: - flags = struct.unpack(" int: """Get player count from TreeStats.net API (same as ThwargLauncher).""" try: async with httpx.AsyncClient() as client: - response = await client.get( - "http://treestats.net/player_counts-latest.json", timeout=10 - ) + response = await client.get("http://treestats.net/player_counts-latest.json", timeout=10) if response.status_code == 200: data = response.json() for server_data in data: @@ -370,68 +257,54 @@ async def get_player_count_from_treestats(server_name: str) -> int: logger.debug(f"Failed to get player count from TreeStats.net: {e}") return 0 - async def monitor_server_health(): """Background task to monitor server health every 30 seconds and cleanup old portals every minute.""" server_name = "Coldeve" server_address = "play.coldeve.ac" server_port = 9000 check_interval = 30 # seconds - player_count_interval = ( - 300 # 5 minutes (like ThwargLauncher's 10 minutes, but more frequent) - ) + player_count_interval = 300 # 5 minutes (like ThwargLauncher's 10 minutes, but more frequent) portal_cleanup_interval = 60 # 1 minute last_player_count_check = 0 last_portal_cleanup = 0 current_player_count = None - + # Initialize server status in database try: existing = await database.fetch_one( "SELECT * FROM server_status WHERE server_name = :name", - {"name": server_name}, + {"name": server_name} ) if not existing: await database.execute( server_status.insert().values( server_name=server_name, current_status="unknown", - total_uptime_seconds=0, + total_uptime_seconds=0 ) ) except Exception as e: logger.error(f"Failed to initialize server status: {e}") - + while True: try: - logger.debug( - f"🏥 Running scheduled health check for {server_name} ({server_address}:{server_port})" - ) + logger.debug(f"🏥 Running scheduled health check for {server_name} ({server_address}:{server_port})") # Check server health via UDP (for status and latency) - is_up, latency_ms, _ = await check_server_health( - server_address, server_port - ) + is_up, latency_ms, _ = await check_server_health(server_address, server_port) status = "up" if is_up else "down" now = datetime.now(timezone.utc) - + # Get player count from TreeStats.net API (like ThwargLauncher) current_time = time.time() - if ( - current_time - last_player_count_check >= player_count_interval - or current_player_count is None - ): + if current_time - last_player_count_check >= player_count_interval or current_player_count is None: new_player_count = await get_player_count_from_treestats(server_name) if new_player_count > 0: # Only update if we got a valid count current_player_count = new_player_count last_player_count_check = current_time - logger.info( - f"🏥 Updated player count from TreeStats.net: {current_player_count}" - ) - - logger.debug( - f"🏥 Health check result: {status}, latency: {latency_ms}, players: {current_player_count}" - ) - + logger.info(f"🏥 Updated player count from TreeStats.net: {current_player_count}") + + logger.debug(f"🏥 Health check result: {status}, latency: {latency_ms}, players: {current_player_count}") + # Record health check await database.execute( server_health_checks.insert().values( @@ -440,43 +313,37 @@ async def monitor_server_health(): timestamp=now, status=status, latency_ms=latency_ms, - player_count=current_player_count, + player_count=current_player_count ) ) - + # Get previous status prev_status = await database.fetch_one( "SELECT * FROM server_status WHERE server_name = :name", - {"name": server_name}, + {"name": server_name} ) - + # Calculate uptime and detect restarts last_restart = prev_status["last_restart"] if prev_status else None - - if ( - prev_status - and prev_status["current_status"] == "down" - and status == "up" - ): + + if prev_status and prev_status["current_status"] == "down" and status == "up": # Server came back up - this is a restart last_restart = now logger.info(f"Server {server_name} came back online") # Broadcast to all browser clients - await _broadcast_to_browser_clients( - { - "type": "server_status", - "server": server_name, - "status": "up", - "message": "Server is back online", - } - ) - + await _broadcast_to_browser_clients({ + "type": "server_status", + "server": server_name, + "status": "up", + "message": "Server is back online" + }) + # Calculate uptime from last restart time (not accumulated) if last_restart and status == "up": uptime_seconds = int((now - last_restart).total_seconds()) else: uptime_seconds = 0 - + # Update server status (always include current_player_count if we have it) await database.execute( """ @@ -500,10 +367,10 @@ async def monitor_server_health(): "uptime": uptime_seconds, "check": now, "latency": latency_ms, - "players": current_player_count, - }, + "players": current_player_count + } ) - + # Update cache global _server_status_cache _server_status_cache = { @@ -512,39 +379,32 @@ async def monitor_server_health(): "player_count": current_player_count, "last_check": now.isoformat(), "uptime_seconds": uptime_seconds, - "last_restart": last_restart.isoformat() if last_restart else None, + "last_restart": last_restart.isoformat() if last_restart else None } - - logger.debug( - f"Server health check: {status}, latency={latency_ms}ms, players={current_player_count}" - ) - + + logger.debug(f"Server health check: {status}, latency={latency_ms}ms, players={current_player_count}") + # Portal cleanup (run every minute) current_time = time.time() if current_time - last_portal_cleanup >= portal_cleanup_interval: try: deleted_count = await cleanup_old_portals() - logger.info( - f"Portal cleanup: removed {deleted_count} portals older than 1 hour" - ) + logger.info(f"Portal cleanup: removed {deleted_count} portals older than 1 hour") last_portal_cleanup = current_time except Exception as cleanup_error: - logger.error( - f"Portal cleanup error: {cleanup_error}", exc_info=True - ) - + logger.error(f"Portal cleanup error: {cleanup_error}", exc_info=True) + except Exception as e: logger.error(f"Server health monitoring error: {e}", exc_info=True) - + await asyncio.sleep(check_interval) - async def cleanup_connections_loop(): """Background task to clean up stale WebSocket connections every 5 minutes.""" cleanup_interval = 300 # 5 minutes - + logger.info("🧹 Starting WebSocket connection cleanup task") - + while True: try: await asyncio.sleep(cleanup_interval) @@ -553,143 +413,122 @@ async def cleanup_connections_loop(): except Exception as e: logger.error(f"WebSocket cleanup task error: {e}", exc_info=True) - def _track_player_changes(new_players: list) -> None: """Track player changes for debugging flapping issues.""" from datetime import datetime, timezone - + # Get current player names current_players = {p["character_name"] for p in new_players} timestamp = datetime.now(timezone.utc) - + # Track telemetry timing for each player for player_data in new_players: player_name = player_data["character_name"] player_timestamp = player_data.get("timestamp") - + # Convert timestamp if it's a string if isinstance(player_timestamp, str): try: - player_timestamp = datetime.fromisoformat( - player_timestamp.replace("Z", "+00:00") - ) + player_timestamp = datetime.fromisoformat(player_timestamp.replace('Z', '+00:00')) except: player_timestamp = timestamp elif player_timestamp is None: player_timestamp = timestamp - + # Initialize player telemetry tracking if needed if player_name not in _player_telemetry_times: _player_telemetry_times[player_name] = [] - + # Add this telemetry timestamp _player_telemetry_times[player_name].append(player_timestamp) - + # Trim to max history if len(_player_telemetry_times[player_name]) > _max_telemetry_history: _player_telemetry_times[player_name].pop(0) - + # Get previous player names if we have history previous_players = set() if _player_history: previous_players = {p["character_name"] for p in _player_history[-1]["players"]} - + # Find players who entered and exited entered_players = current_players - previous_players exited_players = previous_players - current_players - + # Log events with telemetry timing analysis for player in entered_players: # Check if this is due to timing gap timing_gap = None - if ( - player in _player_telemetry_times - and len(_player_telemetry_times[player]) >= 2 - ): + if player in _player_telemetry_times and len(_player_telemetry_times[player]) >= 2: last_two = _player_telemetry_times[player][-2:] timing_gap = (last_two[1] - last_two[0]).total_seconds() - + event = { "timestamp": timestamp, "type": "enter", "character_name": player, "total_players": len(current_players), - "timing_gap": timing_gap, + "timing_gap": timing_gap } _player_events.append(event) - gap_info = ( - f" (gap: {timing_gap:.1f}s)" if timing_gap and timing_gap > 25 else "" - ) - logger.debug( - f"Player entered: {player} (total: {len(current_players)}){gap_info}" - ) - + gap_info = f" (gap: {timing_gap:.1f}s)" if timing_gap and timing_gap > 25 else "" + logger.debug(f"Player entered: {player} (total: {len(current_players)}){gap_info}") + for player in exited_players: # Calculate time since last telemetry last_telemetry_age = None if player in _player_telemetry_times and _player_telemetry_times[player]: last_telemetry = _player_telemetry_times[player][-1] last_telemetry_age = (timestamp - last_telemetry).total_seconds() - + event = { "timestamp": timestamp, - "type": "exit", + "type": "exit", "character_name": player, "total_players": len(current_players), - "last_telemetry_age": last_telemetry_age, + "last_telemetry_age": last_telemetry_age } _player_events.append(event) - age_info = ( - f" (last telemetry: {last_telemetry_age:.1f}s ago)" - if last_telemetry_age - else "" - ) - logger.debug( - f"Player exited: {player} (total: {len(current_players)}){age_info}" - ) - + age_info = f" (last telemetry: {last_telemetry_age:.1f}s ago)" if last_telemetry_age else "" + logger.debug(f"Player exited: {player} (total: {len(current_players)}){age_info}") + # Add current state to history history_entry = { "timestamp": timestamp, "players": new_players, "player_count": len(new_players), - "player_names": list(current_players), + "player_names": list(current_players) } _player_history.append(history_entry) - + # Trim history to max size if len(_player_history) > _max_history_size: _player_history.pop(0) - - # Trim events to max size + + # Trim events to max size if len(_player_events) > _max_events_size: _player_events.pop(0) - def _analyze_flapping_patterns() -> dict: """Analyze player events to identify flapping patterns.""" from collections import Counter, defaultdict - + if not _player_events: - return { - "flapping_players": [], - "frequent_events": [], - "analysis": "No events to analyze", - } - + return {"flapping_players": [], "frequent_events": [], "analysis": "No events to analyze"} + # Count events per player player_event_counts = Counter() player_flap_counts = defaultdict(int) - + # Track recent activity per player (last 10 events) recent_player_activity = defaultdict(list) - + for event in _player_events[-50:]: # Analyze last 50 events player = event["character_name"] event_type = event["type"] player_event_counts[player] += 1 recent_player_activity[player].append(event_type) - + # Identify flapping players (players with many enter/exit cycles) flapping_players = [] for player, activity in recent_player_activity.items(): @@ -697,76 +536,73 @@ def _analyze_flapping_patterns() -> dict: # Count alternating enter/exit patterns flap_score = 0 for i in range(1, len(activity)): - if activity[i] != activity[i - 1]: # Different from previous + if activity[i] != activity[i-1]: # Different from previous flap_score += 1 - + if flap_score >= 3: # At least 3 transitions - flapping_players.append( - { - "character_name": player, - "events": len(activity), - "flap_score": flap_score, - "recent_activity": activity[-10:], # Last 10 events - } - ) - + flapping_players.append({ + "character_name": player, + "events": len(activity), + "flap_score": flap_score, + "recent_activity": activity[-10:] # Last 10 events + }) + # Sort by flap score flapping_players.sort(key=lambda x: x["flap_score"], reverse=True) - + # Most active players frequent_events = [ - {"character_name": player, "event_count": count} + {"character_name": player, "event_count": count} for player, count in player_event_counts.most_common(10) ] - + # Recent activity summary recent_enters = sum(1 for e in _player_events[-20:] if e["type"] == "enter") recent_exits = sum(1 for e in _player_events[-20:] if e["type"] == "exit") - + return { "flapping_players": flapping_players, "frequent_events": frequent_events, "recent_activity": { "enters": recent_enters, "exits": recent_exits, - "net_change": recent_enters - recent_exits, + "net_change": recent_enters - recent_exits }, - "analysis": f"Found {len(flapping_players)} potentially flapping players", + "analysis": f"Found {len(flapping_players)} potentially flapping players" } - def _analyze_telemetry_timing() -> dict: """Analyze telemetry timing patterns for all players.""" from datetime import datetime, timezone - + timing_analysis = {} problem_players = [] - + for player_name, timestamps in _player_telemetry_times.items(): if len(timestamps) < 2: continue - + # Calculate intervals between telemetry messages intervals = [] for i in range(1, len(timestamps)): - interval = (timestamps[i] - timestamps[i - 1]).total_seconds() + interval = (timestamps[i] - timestamps[i-1]).total_seconds() intervals.append(interval) - + if not intervals: continue - + # Calculate timing statistics avg_interval = sum(intervals) / len(intervals) min_interval = min(intervals) max_interval = max(intervals) - + # Count problematic intervals (>30s) long_gaps = [i for i in intervals if i > 30] recent_long_gaps = [i for i in intervals[-5:] if i > 30] # Last 5 intervals - + # Determine if this player has timing issues has_timing_issues = len(long_gaps) > 0 or max_interval > 35 - + timing_stats = { "character_name": player_name, "total_messages": len(timestamps), @@ -775,43 +611,34 @@ def _analyze_telemetry_timing() -> dict: "max_interval": round(max_interval, 1), "long_gaps_count": len(long_gaps), "recent_long_gaps": len(recent_long_gaps), - "last_message_age": ( - datetime.now(timezone.utc) - timestamps[-1] - ).total_seconds() - if timestamps - else 0, + "last_message_age": (datetime.now(timezone.utc) - timestamps[-1]).total_seconds() if timestamps else 0, "has_timing_issues": has_timing_issues, - "recent_intervals": [ - round(i, 1) for i in intervals[-5:] - ], # Last 5 intervals + "recent_intervals": [round(i, 1) for i in intervals[-5:]] # Last 5 intervals } - + timing_analysis[player_name] = timing_stats - + if has_timing_issues: problem_players.append(timing_stats) - + # Sort problem players by severity (max interval) problem_players.sort(key=lambda x: x["max_interval"], reverse=True) - + return { "all_players": timing_analysis, "problem_players": problem_players, "summary": { "total_tracked_players": len(timing_analysis), "players_with_issues": len(problem_players), - "avg_intervals": [ - stats["avg_interval"] for stats in timing_analysis.values() - ], - }, + "avg_intervals": [stats["avg_interval"] for stats in timing_analysis.values()], + } } - async def _refresh_cache_loop() -> None: """Background task: refresh `/live` and `/trails` caches every 5 seconds.""" consecutive_failures = 0 max_consecutive_failures = 5 - + while True: try: # Recompute live players (last 30s) @@ -835,21 +662,19 @@ async def _refresh_cache_loop() -> None: LEFT JOIN char_stats cs ON sub.character_name = cs.character_name """ - + # Use a single connection for both queries to reduce connection churn async with database.connection() as conn: rows = await conn.fetch_all(sql_live, {"cutoff": cutoff}) new_players = [dict(r) for r in rows] - + # Track player changes for debugging _track_player_changes(new_players) - + _cached_live["players"] = new_players - + # Recompute trails (last 600s) - cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta( - seconds=600 - ) + cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600) sql_trail = """ SELECT timestamp, character_name, ew, ns, z FROM telemetry_events @@ -858,34 +683,22 @@ async def _refresh_cache_loop() -> None: """ rows2 = await conn.fetch_all(sql_trail, {"cutoff": cutoff2}) _cached_trails["trails"] = [ - { - "timestamp": r["timestamp"], - "character_name": r["character_name"], - "ew": r["ew"], - "ns": r["ns"], - "z": r["z"], - } + {"timestamp": r["timestamp"], "character_name": r["character_name"], + "ew": r["ew"], "ns": r["ns"], "z": r["z"]} for r in rows2 ] - + # Reset failure counter on success consecutive_failures = 0 - logger.debug( - f"Cache refreshed: {len(_cached_live['players'])} players, {len(_cached_trails['trails'])} trail points" - ) - + logger.debug(f"Cache refreshed: {len(_cached_live['players'])} players, {len(_cached_trails['trails'])} trail points") + except Exception as e: consecutive_failures += 1 - logger.error( - f"Cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", - exc_info=True, - ) - + logger.error(f"Cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True) + # If too many consecutive failures, wait longer and try to reconnect if consecutive_failures >= max_consecutive_failures: - logger.warning( - f"Too many consecutive cache refresh failures. Attempting database reconnection..." - ) + logger.warning(f"Too many consecutive cache refresh failures. Attempting database reconnection...") try: await database.disconnect() await asyncio.sleep(2) @@ -896,29 +709,26 @@ async def _refresh_cache_loop() -> None: logger.error(f"Database reconnection failed: {reconnect_error}") await asyncio.sleep(10) # Wait longer before retrying continue - + await asyncio.sleep(5) - async def _refresh_total_rares_cache() -> None: """Background task: refresh total rares cache every 5 minutes.""" consecutive_failures = 0 max_consecutive_failures = 3 - + while True: try: async with database.connection() as conn: # Get all-time total rares (sum of all characters) - gracefully handle missing table try: - all_time_query = ( - "SELECT COALESCE(SUM(total_rares), 0) as total FROM rare_stats" - ) + all_time_query = "SELECT COALESCE(SUM(total_rares), 0) as total FROM rare_stats" all_time_result = await conn.fetch_one(all_time_query) all_time_total = all_time_result["total"] if all_time_result else 0 except Exception as e: logger.debug(f"rare_stats table not available: {e}") all_time_total = 0 - + # Get today's rares from rare_events table - gracefully handle missing table try: today_query = """ @@ -934,9 +744,7 @@ async def _refresh_total_rares_cache() -> None: # Get total kills from char_stats table (all-time, all characters) try: - kills_query = ( - "SELECT COALESCE(SUM(total_kills), 0) as total FROM char_stats" - ) + kills_query = "SELECT COALESCE(SUM(total_kills), 0) as total FROM char_stats" kills_result = await conn.fetch_one(kills_query) total_kills = kills_result["total"] if kills_result else 0 except Exception as e: @@ -952,43 +760,32 @@ async def _refresh_total_rares_cache() -> None: _cached_total_kills["last_updated"] = datetime.now(timezone.utc) consecutive_failures = 0 - logger.debug( - f"Stats cache updated: Rares all-time: {all_time_total}, today: {today_total}, Kills: {total_kills}" - ) - + logger.debug(f"Stats cache updated: Rares all-time: {all_time_total}, today: {today_total}, Kills: {total_kills}") + except Exception as e: consecutive_failures += 1 - logger.error( - f"Total rares cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", - exc_info=True, - ) - + logger.error(f"Total rares cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True) + if consecutive_failures >= max_consecutive_failures: - logger.warning( - "Too many consecutive total rares cache failures, waiting longer..." - ) + logger.warning("Too many consecutive total rares cache failures, waiting longer...") await asyncio.sleep(60) # Wait longer on repeated failures continue - + # Sleep for 5 minutes (300 seconds) await asyncio.sleep(300) - # ------------------------------------------------------------------ app = FastAPI() # In-memory store mapping character_name to the most recent telemetry snapshot live_snapshots: Dict[str, dict] = {} live_vitals: Dict[str, dict] = {} live_character_stats: Dict[str, dict] = {} -live_equipment_cantrip_states: Dict[str, dict] = {} # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" # LOG_FILE = "telemetry_log.jsonl" # ------------------------------------------------------------------ -ACTIVE_WINDOW = timedelta( - seconds=30 -) # Time window defining “online” players (last 30 seconds) +ACTIVE_WINDOW = timedelta(seconds=30) # Time window defining “online” players (last 30 seconds) """ Data models for plugin events: @@ -1029,7 +826,6 @@ class SpawnEvent(BaseModel): Model for a spawn event emitted by plugin clients when a mob appears. Records character context, mob type, timestamp, and spawn location. """ - character_name: str mob: str timestamp: datetime @@ -1037,13 +833,11 @@ class SpawnEvent(BaseModel): ns: float z: float = 0.0 - class RareEvent(BaseModel): """ Model for a rare mob event when a player encounters or discovers a rare entity. Includes character, event name, timestamp, and location coordinates. """ - character_name: str name: str timestamp: datetime @@ -1057,7 +851,6 @@ class FullInventoryMessage(BaseModel): Model for the full_inventory WebSocket message type. Contains complete character inventory snapshot with raw item data. """ - character_name: str timestamp: datetime item_count: int @@ -1069,7 +862,6 @@ class VitalsMessage(BaseModel): Model for the vitals WebSocket message type. Contains character health, stamina, mana, and vitae information. """ - character_name: str timestamp: datetime health_current: int @@ -1090,7 +882,6 @@ class CharacterStatsMessage(BaseModel): Contains character attributes, skills, allegiance, and progression data. Sent by plugin on login and every 10 minutes. """ - character_name: str timestamp: datetime level: Optional[int] = None @@ -1104,18 +895,12 @@ class CharacterStatsMessage(BaseModel): birth: Optional[str] = None current_title: Optional[int] = None skill_credits: Optional[int] = None - burden: Optional[int] = None - burden_units: Optional[int] = None - encumbrance_capacity: Optional[int] = None attributes: Optional[dict] = None vitals: Optional[dict] = None skills: Optional[dict] = None allegiance: Optional[dict] = None - active_item_enchantments: Optional[list] = None - properties: Optional[dict] = ( - None # Dict[int, int] — DWORD properties (augs, ratings, etc.) - ) - titles: Optional[list] = None # List[str] — character title names + properties: Optional[dict] = None # Dict[int, int] — DWORD properties (augs, ratings, etc.) + titles: Optional[list] = None # List[str] — character title names @app.on_event("startup") @@ -1138,26 +923,18 @@ async def on_startup(): logger.debug(f"Could not access pool details: {pool_error}") break except Exception as e: - logger.warning( - f"Database connection failed (attempt {attempt}/{max_attempts}): {e}" - ) + logger.warning(f"Database connection failed (attempt {attempt}/{max_attempts}): {e}") if attempt < max_attempts: await asyncio.sleep(5) else: - raise RuntimeError( - f"Could not connect to database after {max_attempts} attempts" - ) + raise RuntimeError(f"Could not connect to database after {max_attempts} attempts") # Start background cache refresh (live & trails) global _cache_task, _rares_cache_task, _server_health_task, _cleanup_task _cache_task = asyncio.create_task(_refresh_cache_loop()) _rares_cache_task = asyncio.create_task(_refresh_total_rares_cache()) _server_health_task = asyncio.create_task(monitor_server_health()) _cleanup_task = asyncio.create_task(cleanup_connections_loop()) - logger.info( - "Background cache refresh, server monitoring, and connection cleanup tasks started" - ) - - + logger.info("Background cache refresh, server monitoring, and connection cleanup tasks started") @app.on_event("shutdown") async def on_shutdown(): """Event handler triggered when application is shutting down. @@ -1173,7 +950,7 @@ async def on_shutdown(): await _cache_task except asyncio.CancelledError: pass - + if _rares_cache_task: logger.info("Stopping total rares cache refresh task") _rares_cache_task.cancel() @@ -1181,7 +958,7 @@ async def on_shutdown(): await _rares_cache_task except asyncio.CancelledError: pass - + if _server_health_task: logger.info("Stopping server health monitoring task") _server_health_task.cancel() @@ -1189,7 +966,7 @@ async def on_shutdown(): await _server_health_task except asyncio.CancelledError: pass - + if _cleanup_task: logger.info("Stopping WebSocket connection cleanup task") _cleanup_task.cancel() @@ -1197,65 +974,53 @@ async def on_shutdown(): await _cleanup_task except asyncio.CancelledError: pass - # Cancel any in-flight broadcast tasks - if _broadcast_tasks: - logger.info(f"Cancelling {len(_broadcast_tasks)} in-flight broadcast tasks") - for task in _broadcast_tasks: - task.cancel() - await asyncio.gather(*_broadcast_tasks, return_exceptions=True) - _broadcast_tasks.clear() - logger.info("Disconnecting from database") await database.disconnect() + # ------------------------ GET ----------------------------------- @app.get("/debug") def debug(): return {"status": "OK"} - @app.get("/debug/player-flapping") async def get_player_flapping_debug(): """Return player tracking data for debugging flapping issues.""" try: # Analyze flapping patterns flapping_analysis = _analyze_flapping_patterns() - + # Analyze telemetry timing timing_analysis = _analyze_telemetry_timing() - + # Get recent events (last 50) - recent_events = ( - _player_events[-50:] if len(_player_events) > 50 else _player_events - ) - + recent_events = _player_events[-50:] if len(_player_events) > 50 else _player_events + # Convert timestamps to ISO format for JSON serialization formatted_events = [] for event in recent_events: formatted_event = event.copy() formatted_event["timestamp"] = event["timestamp"].isoformat() formatted_events.append(formatted_event) - + # Format history formatted_history = [] for entry in _player_history: formatted_entry = { "timestamp": entry["timestamp"].isoformat(), "player_count": entry["player_count"], - "player_names": entry["player_names"], + "player_names": entry["player_names"] } formatted_history.append(formatted_entry) - + # Format timing data for JSON serialization formatted_timing = {} for player_name, timing_data in timing_analysis["all_players"].items(): formatted_timing[player_name] = timing_data.copy() # Round last_message_age for readability - formatted_timing[player_name]["last_message_age"] = round( - timing_data["last_message_age"], 1 - ) - + formatted_timing[player_name]["last_message_age"] = round(timing_data["last_message_age"], 1) + return { "current_players": len(_cached_live.get("players", [])), "history": formatted_history, @@ -1264,21 +1029,20 @@ async def get_player_flapping_debug(): "timing_analysis": { "all_players": formatted_timing, "problem_players": timing_analysis["problem_players"], - "summary": timing_analysis["summary"], + "summary": timing_analysis["summary"] }, "tracking_stats": { "history_entries": len(_player_history), "total_events": len(_player_events), "tracked_players": len(_player_telemetry_times), "max_history_size": _max_history_size, - "max_events_size": _max_events_size, - }, + "max_events_size": _max_events_size + } } except Exception as e: logger.error(f"Failed to get player flapping debug data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/debug/websocket-health") async def get_websocket_health(): """Return simple WebSocket connection counts.""" @@ -1286,30 +1050,26 @@ async def get_websocket_health(): return { "plugin_connections": _plugin_connections, "browser_connections": _browser_connections, - "total_connections": _plugin_connections + _browser_connections, + "total_connections": _plugin_connections + _browser_connections } except Exception as e: logger.error(f"Failed to get WebSocket health data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/debug/database-performance") async def get_database_performance(): """Return simple database query performance statistics.""" try: - avg_query_time = ( - (_total_query_time / _total_queries) if _total_queries > 0 else 0.0 - ) + avg_query_time = (_total_query_time / _total_queries) if _total_queries > 0 else 0.0 return { "total_queries": _total_queries, "total_query_time": round(_total_query_time, 3), - "average_query_time": round(avg_query_time, 3), + "average_query_time": round(avg_query_time, 3) } except Exception as e: logger.error(f"Failed to get database performance data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/debug/recent-activity") async def get_recent_activity(): """Return recent telemetry activity feed.""" @@ -1317,56 +1077,43 @@ async def get_recent_activity(): return { "recent_messages": _recent_telemetry_messages.copy(), "total_messages": len(_recent_telemetry_messages), - "max_messages": _max_recent_messages, + "max_messages": _max_recent_messages } except Exception as e: logger.error(f"Failed to get recent activity data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/server-health") async def get_server_health(): """Return current server health status.""" try: # Get latest status from database if cache is stale - if not _server_status_cache.get("last_check") or ( - datetime.now(timezone.utc) - - datetime.fromisoformat( - _server_status_cache["last_check"].replace("Z", "+00:00") - ) - > timedelta(minutes=2) - ): + if not _server_status_cache.get("last_check") or \ + (datetime.now(timezone.utc) - datetime.fromisoformat(_server_status_cache["last_check"].replace('Z', '+00:00')) > timedelta(minutes=2)): + row = await database.fetch_one( "SELECT * FROM server_status WHERE server_name = :name", - {"name": "Coldeve"}, + {"name": "Coldeve"} ) - + if row: - _server_status_cache.update( - { - "status": row["current_status"], - "latency_ms": row["last_latency_ms"], - "player_count": row["last_player_count"], - "last_check": row["last_check"].isoformat() - if row["last_check"] - else None, - "uptime_seconds": row["total_uptime_seconds"], - "last_restart": row["last_restart"].isoformat() - if row["last_restart"] - else None, - } - ) - + _server_status_cache.update({ + "status": row["current_status"], + "latency_ms": row["last_latency_ms"], + "player_count": row["last_player_count"], + "last_check": row["last_check"].isoformat() if row["last_check"] else None, + "uptime_seconds": row["total_uptime_seconds"], + "last_restart": row["last_restart"].isoformat() if row["last_restart"] else None + }) + # Format uptime uptime_seconds = _server_status_cache.get("uptime_seconds", 0) days = uptime_seconds // 86400 hours = (uptime_seconds % 86400) // 3600 minutes = (uptime_seconds % 3600) // 60 - - uptime_str = ( - f"{days}d {hours}h {minutes}m" if days > 0 else f"{hours}h {minutes}m" - ) - + + uptime_str = f"{days}d {hours}h {minutes}m" if days > 0 else f"{hours}h {minutes}m" + return { "server_name": "Coldeve", "status": _server_status_cache.get("status", "unknown"), @@ -1375,14 +1122,13 @@ async def get_server_health(): "uptime": uptime_str, "uptime_seconds": uptime_seconds, "last_restart": _server_status_cache.get("last_restart"), - "last_check": _server_status_cache.get("last_check"), + "last_check": _server_status_cache.get("last_check") } - + except Exception as e: logger.error(f"Failed to get server health data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/quest-status") async def get_quest_status(): """Return current cached quest status for all players.""" @@ -1392,16 +1138,15 @@ async def get_quest_status(): "quest_data": _quest_status_cache, "tracked_quests": [ "Stipend Collection Timer", - "Blank Augmentation Gem Pickup Timer", - "Insatiable Eater Jaw", + "Blank Augmentation Gem Pickup Timer", + "Insatiable Eater Jaw" ], - "player_count": len(_quest_status_cache), + "player_count": len(_quest_status_cache) } except Exception as e: logger.error(f"Failed to get quest status data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/portals") async def get_portals(): """Return all active portals (less than 1 hour old).""" @@ -1412,26 +1157,32 @@ async def get_portals(): FROM portals ORDER BY discovered_at DESC """ - + rows = await database.fetch_all(query) - + portals = [] for row in rows: portal = { "portal_name": row["portal_name"], - "coordinates": {"ns": row["ns"], "ew": row["ew"], "z": row["z"]}, + "coordinates": { + "ns": row["ns"], + "ew": row["ew"], + "z": row["z"] + }, "discovered_at": row["discovered_at"].isoformat(), - "discovered_by": row["discovered_by"], + "discovered_by": row["discovered_by"] } portals.append(portal) - - return {"portals": portals, "portal_count": len(portals)} - + + return { + "portals": portals, + "portal_count": len(portals) + } + except Exception as e: logger.error(f"Failed to get portals data: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/live", response_model=dict) @app.get("/live/", response_model=dict) async def get_live_players(): @@ -1443,6 +1194,8 @@ async def get_live_players(): raise HTTPException(status_code=500, detail="Internal server error") + + # --- GET Trails --------------------------------- @app.get("/trails") @app.get("/trails/") @@ -1482,19 +1235,15 @@ async def get_total_kills(): # --- 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" - ), + 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}, ...], @@ -1504,7 +1253,7 @@ async def get_spawn_heatmap_data( """ 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 @@ -1514,30 +1263,28 @@ async def get_spawn_heatmap_data( 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"]), + "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, + "hours_window": hours } - - logger.debug( - f"Heat map data: {len(spawn_points)} unique spawn locations from last {hours} 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") @@ -1552,29 +1299,22 @@ async def get_character_inventory(character_name: str): response = await client.get( f"{INVENTORY_SERVICE_URL}/inventory/{character_name}" ) - + if response.status_code == 200: return JSONResponse(content=response.json()) elif response.status_code == 404: - raise HTTPException( - status_code=404, - detail=f"No inventory found for character '{character_name}'", - ) + raise HTTPException(status_code=404, detail=f"No inventory found for character '{character_name}'") else: - logger.error( - f"Inventory service returned {response.status_code} for {character_name}" - ) + logger.error(f"Inventory service returned {response.status_code} for {character_name}") raise HTTPException(status_code=502, 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 get inventory for {character_name}: {e}", exc_info=True - ) + logger.error(f"Failed to get inventory for {character_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -1586,72 +1326,66 @@ async def search_character_inventory( min_value: int = Query(None, description="Minimum item value"), max_value: int = Query(None, description="Maximum item value"), min_burden: int = Query(None, description="Minimum burden"), - max_burden: int = Query(None, description="Maximum burden"), + max_burden: int = Query(None, description="Maximum burden") ): """Search and filter inventory items for a character with various criteria.""" try: conditions = ["character_name = :character_name"] params = {"character_name": character_name} - + if name: conditions.append("name ILIKE :name") params["name"] = f"%{name}%" - + if object_class is not None: conditions.append("object_class = :object_class") params["object_class"] = object_class - + if min_value is not None: conditions.append("value >= :min_value") params["min_value"] = min_value - + if max_value is not None: conditions.append("value <= :max_value") params["max_value"] = max_value - + if min_burden is not None: conditions.append("burden >= :min_burden") params["min_burden"] = min_burden - + if max_burden is not None: conditions.append("burden <= :max_burden") params["max_burden"] = max_burden - + query = f""" SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp FROM character_inventories - WHERE {" AND ".join(conditions)} + WHERE {' AND '.join(conditions)} ORDER BY value DESC, name """ - + rows = await database.fetch_all(query, params) - + items = [] for row in rows: item = dict(row) items.append(item) - - return JSONResponse( - content=jsonable_encoder( - { - "character_name": character_name, - "item_count": len(items), - "search_criteria": { - "name": name, - "object_class": object_class, - "min_value": min_value, - "max_value": max_value, - "min_burden": min_burden, - "max_burden": max_burden, - }, - "items": items, - } - ) - ) + + return JSONResponse(content=jsonable_encoder({ + "character_name": character_name, + "item_count": len(items), + "search_criteria": { + "name": name, + "object_class": object_class, + "min_value": min_value, + "max_value": max_value, + "min_burden": min_burden, + "max_burden": max_burden + }, + "items": items + })) except Exception as e: - logger.error( - f"Failed to search inventory for {character_name}: {e}", exc_info=True - ) + logger.error(f"Failed to search inventory for {character_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -1666,22 +1400,19 @@ async def list_characters_with_inventories(): ORDER BY last_updated DESC """ rows = await database.fetch_all(query) - + characters = [] for row in rows: - characters.append( - { - "character_name": row["character_name"], - "item_count": row["item_count"], - "last_updated": row["last_updated"], - } - ) - - return JSONResponse( - content=jsonable_encoder( - {"characters": characters, "total_characters": len(characters)} - ) - ) + characters.append({ + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"] + }) + + return JSONResponse(content=jsonable_encoder({ + "characters": characters, + "total_characters": len(characters) + })) except Exception as e: logger.error(f"Failed to list inventory characters: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -1694,37 +1425,24 @@ async def get_inventory_characters(): 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", - ) - + 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" - ) + 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" - ), + 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" - ), + 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 @@ -1734,18 +1452,14 @@ async def search_items_proxy( # 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" - ), + 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_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"), @@ -1763,109 +1477,69 @@ async def search_items_proxy( 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_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"), + 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 + 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 + 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 + 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 + 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 + 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", - ) - + 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") @@ -1879,44 +1553,35 @@ async def search_items_proxy( @app.get("/search/equipped/{character_name}") async def search_equipped_items_proxy( character_name: str, - slot: int = Query(None, description="Specific equipment slot mask"), + 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, + 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}'", - ) + 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" - ) - + 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 - ) + logger.error(f"Failed to search equipped items for {character_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -1924,45 +1589,33 @@ async def search_equipped_items_proxy( 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" - ), + 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, + 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}", - ) + 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" - ) - + 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, - ) + 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") @@ -1972,17 +1625,13 @@ async def list_equipment_sets_proxy(): 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" - ) - + 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") @@ -2000,46 +1649,32 @@ browser_conns: set[WebSocket] = set() # Mapping of plugin clients by character_name to their WebSocket for command forwarding plugin_conns: Dict[str, WebSocket] = {} - -async def _send_to_browser(ws: WebSocket, data: dict) -> WebSocket | None: - """Send data to a single browser client. Returns the ws if it failed, None if ok.""" - try: - await asyncio.wait_for(ws.send_json(data), timeout=1.0) - except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e: - logger.debug(f"Detected disconnected browser client: {e}") - return ws - except asyncio.TimeoutError: - logger.warning( - "Timed out broadcasting to browser client; removing stale connection" - ) - return ws - except Exception as e: - logger.warning(f"Unexpected error broadcasting to browser client: {e}") - return ws - return None - - -async def _do_broadcast(data: dict): - """Send data to all browser clients concurrently. Runs as a background task.""" - clients = list(browser_conns) - if not clients: - return - results = await asyncio.gather(*(_send_to_browser(ws, data) for ws in clients)) - for ws in results: - if ws is not None: - browser_conns.discard(ws) - - async def _broadcast_to_browser_clients(snapshot: dict): """Broadcast a telemetry or chat message to all connected browser clients. - Fires off a background task so the plugin receive loop is never blocked - by slow browser connections. + Converts any non-serializable types (e.g., datetime) before sending. + Handles connection errors gracefully and removes stale connections. """ + # Convert snapshot payload to JSON-friendly types data = jsonable_encoder(snapshot) - task = asyncio.create_task(_do_broadcast(data)) - _broadcast_tasks.add(task) - task.add_done_callback(_broadcast_tasks.discard) + # Use list() to avoid "set changed size during iteration" errors + disconnected_clients = [] + + for ws in list(browser_conns): + try: + await ws.send_json(data) + except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e: + # Collect disconnected clients for cleanup + disconnected_clients.append(ws) + logger.debug(f"Detected disconnected browser client: {e}") + except Exception as e: + # Handle any other unexpected errors + disconnected_clients.append(ws) + logger.warning(f"Unexpected error broadcasting to browser client: {e}") + + # Clean up disconnected clients + for ws in disconnected_clients: + browser_conns.discard(ws) async def _forward_to_inventory_service(inventory_msg: FullInventoryMessage): @@ -2049,55 +1684,48 @@ async def _forward_to_inventory_service(inventory_msg: FullInventoryMessage): inventory_data = { "character_name": inventory_msg.character_name, "timestamp": inventory_msg.timestamp.isoformat(), - "items": inventory_msg.items, + "items": inventory_msg.items } - + async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( - f"{INVENTORY_SERVICE_URL}/process-inventory", json=inventory_data + f"{INVENTORY_SERVICE_URL}/process-inventory", + json=inventory_data ) - + if response.status_code == 200: result = response.json() - logger.info( - f"Inventory service processed {result['processed']} items for {inventory_msg.character_name}" - ) + logger.info(f"Inventory service processed {result['processed']} items for {inventory_msg.character_name}") else: - logger.error( - f"Inventory service error {response.status_code}: {response.text}" - ) - + logger.error(f"Inventory service error {response.status_code}: {response.text}") + except Exception as e: logger.error(f"Failed to forward inventory to service: {e}") # Don't raise - this shouldn't block the main storage - async def _store_inventory(inventory_msg: FullInventoryMessage): """Forward inventory data to inventory microservice for processing and storage.""" try: # Forward to inventory microservice for enhanced processing and storage await _forward_to_inventory_service(inventory_msg) - + # Optional: Create JSON file for debugging (can be removed in production) inventory_dir = Path("./inventory") inventory_dir.mkdir(exist_ok=True) - + file_path = inventory_dir / f"{inventory_msg.character_name}_inventory.json" inventory_data = { "character_name": inventory_msg.character_name, "timestamp": inventory_msg.timestamp.isoformat(), "item_count": inventory_msg.item_count, - "items": inventory_msg.items, + "items": inventory_msg.items } - - with open(file_path, "w") as f: + + with open(file_path, 'w') as f: json.dump(inventory_data, f, indent=2) - + except Exception as e: - logger.error( - f"Failed to forward inventory for {inventory_msg.character_name}: {e}", - exc_info=True, - ) + logger.error(f"Failed to forward inventory for {inventory_msg.character_name}: {e}", exc_info=True) raise @@ -2105,7 +1733,7 @@ async def _store_inventory(inventory_msg: FullInventoryMessage): async def ws_receive_snapshots( websocket: WebSocket, secret: str | None = Query(None), - x_plugin_secret: str | None = Header(None), + x_plugin_secret: str | None = Header(None) ): """WebSocket endpoint for plugin clients to send telemetry and events. @@ -2118,29 +1746,27 @@ async def ws_receive_snapshots( - chat: broadcast chat messages to browsers """ global _plugin_connections - + # Authenticate plugin connection using shared secret key = secret or x_plugin_secret if key != SHARED_SECRET: # Reject without completing the WebSocket handshake - logger.warning( - f"Plugin WebSocket authentication failed from {websocket.client}" - ) + logger.warning(f"Plugin WebSocket authentication failed from {websocket.client}") await websocket.close(code=1008) return # Accept the WebSocket connection await websocket.accept() logger.info(f"🔌 PLUGIN_CONNECTED: {websocket.client}") - + # Track plugin connection _plugin_connections += 1 - + try: while True: # Read next text frame try: raw = await websocket.receive_text() - # Debug: log all incoming plugin WebSocket messages + # Debug: log all incoming plugin WebSocket messages logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}") except WebSocketDisconnect: logger.info(f"🔌 PLUGIN_DISCONNECTED: {websocket.client}") @@ -2157,7 +1783,6 @@ async def ws_receive_snapshots( name = data.get("character_name") or data.get("player_name") if isinstance(name, str): plugin_conns[name] = websocket - live_equipment_cantrip_states.pop(name, None) logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}") continue # --- Spawn event: persist to spawn_events table --- @@ -2166,10 +1791,10 @@ async def ws_receive_snapshots( payload.pop("type", None) try: spawn = SpawnEvent.parse_obj(payload) - await database.execute(spawn_events.insert().values(**spawn.dict())) - logger.debug( - f"Recorded spawn event: {spawn.mob} by {spawn.character_name}" + await database.execute( + spawn_events.insert().values(**spawn.dict()) ) + logger.debug(f"Recorded spawn event: {spawn.mob} by {spawn.character_name}") except Exception as e: logger.error(f"Failed to process spawn event: {e}") continue @@ -2179,22 +1804,20 @@ async def ws_receive_snapshots( # Parse telemetry snapshot and update in-memory state payload = data.copy() payload.pop("type", None) - character_name = payload.get("character_name", "unknown") - + character_name = payload.get('character_name', 'unknown') + # Track message receipt and start timing telemetry_start_time = time.time() - logger.info( - f"📨 TELEMETRY_RECEIVED: {character_name} from {websocket.client}" - ) - + logger.info(f"📨 TELEMETRY_RECEIVED: {character_name} from {websocket.client}") + try: snap = TelemetrySnapshot.parse_obj(payload) live_snapshots[snap.character_name] = snap.dict() # Prepare data and compute kill delta db_data = snap.dict() - db_data["rares_found"] = 0 + db_data['rares_found'] = 0 key = (snap.session_id, snap.character_name) - + # Get last recorded kill count for this session if key in ws_receive_snapshots._last_kills: last = ws_receive_snapshots._last_kills[key] @@ -2202,68 +1825,48 @@ async def ws_receive_snapshots( # Cache miss - check database for last kill count for this session row = await database.fetch_one( "SELECT kills FROM telemetry_events WHERE character_name = :char AND session_id = :session ORDER BY timestamp DESC LIMIT 1", - {"char": snap.character_name, "session": snap.session_id}, + {"char": snap.character_name, "session": snap.session_id} ) last = row["kills"] if row else 0 - logger.debug( - f"Cache miss for {snap.character_name} session {snap.session_id[:8]}: loaded last_kills={last} from database" - ) - + logger.debug(f"Cache miss for {snap.character_name} session {snap.session_id[:8]}: loaded last_kills={last} from database") + delta = snap.kills - last # Persist snapshot and any kill delta in a single transaction db_start_time = time.time() - + # Log connection pool status before database operation try: - pool_status = ( - f"pool_size:{database._pool._queue.qsize()}" - if hasattr(database, "_pool") - and hasattr(database._pool, "_queue") - else "pool_status:unknown" - ) + pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown" except: pool_status = "pool_status:error" - - logger.info( - f"💾 TELEMETRY_DB_WRITE_ATTEMPT: {snap.character_name} session:{snap.session_id[:8]} kills:{snap.kills} delta:{delta} {pool_status}" - ) - + + logger.info(f"💾 TELEMETRY_DB_WRITE_ATTEMPT: {snap.character_name} session:{snap.session_id[:8]} kills:{snap.kills} delta:{delta} {pool_status}") + try: async with database.transaction(): await database.execute( telemetry_events.insert().values(**db_data) ) if delta > 0: - stmt = ( - pg_insert(char_stats) - .values( - character_name=snap.character_name, - total_kills=delta, - ) - .on_conflict_do_update( - index_elements=["character_name"], - set_={ - "total_kills": char_stats.c.total_kills - + delta - }, - ) + stmt = pg_insert(char_stats).values( + character_name=snap.character_name, + total_kills=delta + ).on_conflict_do_update( + index_elements=["character_name"], + set_={"total_kills": char_stats.c.total_kills + delta}, ) await database.execute(stmt) - logger.debug( - f"Updated kills for {snap.character_name}: +{delta} (total from {last} to {snap.kills})" - ) - + logger.debug(f"Updated kills for {snap.character_name}: +{delta} (total from {last} to {snap.kills})") + # Success: log timing and update cache db_duration = (time.time() - db_start_time) * 1000 ws_receive_snapshots._last_kills[key] = snap.kills - + # Track database performance (Phase 2) global _total_queries, _total_query_time _total_queries += 1 - _total_query_time += ( - db_duration / 1000.0 - ) # Convert ms to seconds - + _total_query_time += db_duration / 1000.0 # Convert ms to seconds + # Track recent activity (Phase 3) global _recent_telemetry_messages, _max_recent_messages activity_entry = { @@ -2271,61 +1874,43 @@ async def ws_receive_snapshots( "character_name": snap.character_name, "kills": snap.kills, "kill_delta": delta, - "query_time": round(db_duration, 1), + "query_time": round(db_duration, 1) } _recent_telemetry_messages.append(activity_entry) if len(_recent_telemetry_messages) > _max_recent_messages: _recent_telemetry_messages.pop(0) - + + # Log final pool status after successful operation try: - final_pool_status = ( - f"pool_size:{database._pool._queue.qsize()}" - if hasattr(database, "_pool") - and hasattr(database._pool, "_queue") - else "pool_status:unknown" - ) + final_pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown" except: final_pool_status = "pool_status:error" - - logger.info( - f"✅ TELEMETRY_DB_WRITE_SUCCESS: {snap.character_name} took {db_duration:.1f}ms {final_pool_status}" - ) - + + logger.info(f"✅ TELEMETRY_DB_WRITE_SUCCESS: {snap.character_name} took {db_duration:.1f}ms {final_pool_status}") + except Exception as db_error: db_duration = (time.time() - db_start_time) * 1000 - + + # Log pool status during failure try: - error_pool_status = ( - f"pool_size:{database._pool._queue.qsize()}" - if hasattr(database, "_pool") - and hasattr(database._pool, "_queue") - else "pool_status:unknown" - ) + error_pool_status = f"pool_size:{database._pool._queue.qsize()}" if hasattr(database, '_pool') and hasattr(database._pool, '_queue') else "pool_status:unknown" except: error_pool_status = "pool_status:error" - - logger.error( - f"❌ TELEMETRY_DB_WRITE_FAILED: {snap.character_name} session:{snap.session_id[:8]} took {db_duration:.1f}ms {error_pool_status} error:{db_error}", - exc_info=True, - ) + + logger.error(f"❌ TELEMETRY_DB_WRITE_FAILED: {snap.character_name} session:{snap.session_id[:8]} took {db_duration:.1f}ms {error_pool_status} error:{db_error}", exc_info=True) continue # Broadcast updated snapshot to all browser clients await _broadcast_to_browser_clients(snap.dict()) - + # Log successful processing completion with timing total_duration = (time.time() - telemetry_start_time) * 1000 - logger.info( - f"⏱️ TELEMETRY_PROCESSING_COMPLETE: {snap.character_name} took {total_duration:.1f}ms total" - ) - + logger.info(f"⏱️ TELEMETRY_PROCESSING_COMPLETE: {snap.character_name} took {total_duration:.1f}ms total") + except Exception as e: total_duration = (time.time() - telemetry_start_time) * 1000 - logger.error( - f"❌ TELEMETRY_PROCESSING_FAILED: {character_name} took {total_duration:.1f}ms error:{e}", - exc_info=True, - ) + logger.error(f"❌ TELEMETRY_PROCESSING_FAILED: {character_name} took {total_duration:.1f}ms error:{e}", exc_info=True) continue # --- Rare event: update total and session counters and persist --- if msg_type == "rare": @@ -2333,13 +1918,12 @@ async def ws_receive_snapshots( if isinstance(name, str) and name.strip(): try: # Total rare count per character - stmt_tot = ( - pg_insert(rare_stats) - .values(character_name=name, total_rares=1) - .on_conflict_do_update( - index_elements=["character_name"], - set_={"total_rares": rare_stats.c.total_rares + 1}, - ) + stmt_tot = pg_insert(rare_stats).values( + character_name=name, + total_rares=1 + ).on_conflict_do_update( + index_elements=["character_name"], + set_={"total_rares": rare_stats.c.total_rares + 1}, ) await database.execute(stmt_tot) # Session-specific rare count (use live cache or fallback to latest telemetry) @@ -2349,25 +1933,18 @@ async def ws_receive_snapshots( "SELECT session_id FROM telemetry_events" " WHERE character_name = :name" " ORDER BY timestamp DESC LIMIT 1", - {"name": name}, + {"name": name} ) if row: session_id = row["session_id"] if session_id: - stmt_sess = ( - pg_insert(rare_stats_sessions) - .values( - character_name=name, - session_id=session_id, - session_rares=1, - ) - .on_conflict_do_update( - index_elements=["character_name", "session_id"], - set_={ - "session_rares": rare_stats_sessions.c.session_rares - + 1 - }, - ) + stmt_sess = pg_insert(rare_stats_sessions).values( + character_name=name, + session_id=session_id, + session_rares=1 + ).on_conflict_do_update( + index_elements=["character_name", "session_id"], + set_={"session_rares": rare_stats_sessions.c.session_rares + 1}, ) await database.execute(stmt_sess) # Persist individual rare event for future analysis @@ -2378,25 +1955,18 @@ async def ws_receive_snapshots( await database.execute( rare_events.insert().values(**rare_ev.dict()) ) - logger.info( - f"Recorded rare event: {rare_ev.name} by {name}" - ) + logger.info(f"Recorded rare event: {rare_ev.name} by {name}") # Broadcast rare event to browser clients for epic notifications await _broadcast_to_browser_clients(data) except Exception as e: logger.error(f"Failed to persist rare event: {e}") except Exception as e: - logger.error( - f"Failed to process rare event for {name}: {e}", - exc_info=True, - ) + logger.error(f"Failed to process rare event for {name}: {e}", exc_info=True) continue # --- Chat message: forward chat payload to browser clients --- if msg_type == "chat": await _broadcast_to_browser_clients(data) - logger.debug( - f"Broadcasted chat message from {data.get('character_name', 'unknown')}" - ) + logger.debug(f"Broadcasted chat message from {data.get('character_name', 'unknown')}") continue # --- Full inventory message: store complete inventory snapshot --- if msg_type == "full_inventory": @@ -2405,14 +1975,9 @@ async def ws_receive_snapshots( try: inventory_msg = FullInventoryMessage.parse_obj(payload) await _store_inventory(inventory_msg) - logger.info( - f"Stored inventory for {inventory_msg.character_name}: {inventory_msg.item_count} items" - ) + logger.info(f"Stored inventory for {inventory_msg.character_name}: {inventory_msg.item_count} items") except Exception as e: - logger.error( - f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", - exc_info=True, - ) + logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue # --- Inventory delta: single item add/remove/update --- if msg_type == "inventory_delta": @@ -2428,40 +1993,23 @@ async def ws_receive_snapshots( f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item/{item_id}" ) if resp.status_code >= 400: - logger.warning( - f"Inventory service returned {resp.status_code} for delta remove item_id={item_id}" - ) + logger.warning(f"Inventory service returned {resp.status_code} for delta remove item_id={item_id}") elif action in ("add", "update"): item = data.get("item") if item: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item", - json=item, + json=item ) - if resp.status_code < 400: - # Use enriched item from inventory-service response for broadcast - resp_json = resp.json() - enriched_item = resp_json.get("item") - if enriched_item: - data = { - "type": "inventory_delta", - "action": action, - "character_name": char_name, - "item": enriched_item, - } - else: - logger.warning( - f"Inventory service returned {resp.status_code} for delta {action}" - ) + if resp.status_code >= 400: + logger.warning(f"Inventory service returned {resp.status_code} for delta {action}") # Broadcast delta to all browser clients await _broadcast_to_browser_clients(data) logger.debug(f"Inventory delta ({action}) for {char_name}") except Exception as e: - logger.error( - f"Failed to process inventory delta: {e}", exc_info=True - ) + logger.error(f"Failed to process inventory delta: {e}", exc_info=True) continue # --- Vitals message: store character health/stamina/mana and broadcast --- if msg_type == "vitals": @@ -2471,14 +2019,9 @@ async def ws_receive_snapshots( vitals_msg = VitalsMessage.parse_obj(payload) live_vitals[vitals_msg.character_name] = vitals_msg.dict() await _broadcast_to_browser_clients(data) - logger.debug( - f"Updated vitals for {vitals_msg.character_name}: {vitals_msg.health_percentage}% HP, {vitals_msg.stamina_percentage}% Stam, {vitals_msg.mana_percentage}% Mana" - ) + logger.debug(f"Updated vitals for {vitals_msg.character_name}: {vitals_msg.health_percentage}% HP, {vitals_msg.stamina_percentage}% Stam, {vitals_msg.mana_percentage}% Mana") except Exception as e: - logger.error( - f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", - exc_info=True, - ) + logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue # --- Character stats message: store character attributes/skills/progression and broadcast --- if msg_type == "character_stats": @@ -2493,23 +2036,9 @@ async def ws_receive_snapshots( # Build stats_data JSONB (everything except extracted columns) stats_data = {} - for key in ( - "attributes", - "vitals", - "skills", - "allegiance", - "active_item_enchantments", - "race", - "gender", - "birth", - "current_title", - "skill_credits", - "burden", - "burden_units", - "encumbrance_capacity", - "properties", - "titles", - ): + for key in ("attributes", "vitals", "skills", "allegiance", + "race", "gender", "birth", "current_title", "skill_credits", + "properties", "titles"): if stats_dict.get(key) is not None: stats_data[key] = stats_dict[key] @@ -2533,98 +2062,70 @@ async def ws_receive_snapshots( stats_data = EXCLUDED.stats_data """, { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }, - ) + "character_name": stats_msg.character_name, + "timestamp": stats_msg.timestamp, + "level": stats_msg.level, + "total_xp": stats_msg.total_xp, + "unassigned_xp": stats_msg.unassigned_xp, + "luminance_earned": stats_msg.luminance_earned, + "luminance_total": stats_msg.luminance_total, + "deaths": stats_msg.deaths, + "stats_data": json.dumps(stats_data), + }) # Broadcast to browser clients await _broadcast_to_browser_clients(data) - logger.info( - f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}" - ) + logger.info(f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}") except Exception as e: - logger.error( - f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", - exc_info=True, - ) - continue - # --- Equipment cantrip state: live-only overlay for mana panel --- - if msg_type == "equipment_cantrip_state": - try: - character_name = data.get("character_name") - if character_name: - live_equipment_cantrip_states[character_name] = data - await _broadcast_to_browser_clients(data) - logger.debug( - f"Updated equipment cantrip state for {character_name}" - ) - except Exception as e: - logger.error( - f"Failed to process equipment_cantrip_state for {data.get('character_name', 'unknown')}: {e}", - exc_info=True, - ) + logger.error(f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue # --- Quest message: update cache and broadcast (no database storage) --- if msg_type == "quest": character_name = data.get("character_name") quest_name = data.get("quest_name") countdown = data.get("countdown") - + if character_name and quest_name and countdown is not None: # Only track specific quest types allowed_quests = { "Stipend Collection Timer", - "Blank Augmentation Gem Pickup Timer", - "Insatiable Eater Jaw", + "Blank Augmentation Gem Pickup Timer", + "Insatiable Eater Jaw" } - + if quest_name in allowed_quests: # Update quest cache if character_name not in _quest_status_cache: _quest_status_cache[character_name] = {} _quest_status_cache[character_name][quest_name] = countdown - + # Broadcast to browser clients for real-time updates await _broadcast_to_browser_clients(data) - logger.debug( - f"Updated quest status for {character_name}: {quest_name} = {countdown}" - ) + logger.debug(f"Updated quest status for {character_name}: {quest_name} = {countdown}") else: logger.debug(f"Ignoring non-tracked quest: {quest_name}") else: - logger.warning( - f"Invalid quest message format from {websocket.client}: missing required fields" - ) + logger.warning(f"Invalid quest message format from {websocket.client}: missing required fields") continue # --- Portal message: store in database and broadcast --- if msg_type == "portal": character_name = data.get("character_name") portal_name = data.get("portal_name") ns = data.get("ns") - ew = data.get("ew") + ew = data.get("ew") z = data.get("z") timestamp_str = data.get("timestamp") - + if all([character_name, portal_name, ns, ew, z, timestamp_str]): try: # Parse timestamp - timestamp = datetime.fromisoformat( - timestamp_str.replace("Z", "+00:00") - ) - + timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + # Convert coordinates to floats for database storage ns = float(ns) ew = float(ew) z = float(z) - + # Round coordinates for display (0.1 tolerance to match DB constraint) ns_rounded = round(ns, 1) ew_rounded = round(ew, 1) @@ -2648,56 +2149,39 @@ async def ws_receive_snapshots( "ew": ew, "z": z, "timestamp": timestamp, - "character_name": character_name, - }, + "character_name": character_name + } ) # Log whether this was a new discovery or an update # xmax = 0 means it was an INSERT (new portal) # xmax != 0 means it was an UPDATE (existing portal) if result and result["was_inserted"]: - logger.info( - f"New portal discovered: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}" - ) + logger.info(f"New portal discovered: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}") else: - logger.debug( - f"Portal timestamp updated: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}" - ) - + logger.debug(f"Portal timestamp updated: {portal_name} at {ns_rounded}, {ew_rounded} by {character_name}") + # Broadcast to browser clients for map updates await _broadcast_to_browser_clients(data) - + except Exception as e: - logger.error( - f"Failed to process portal discovery for {character_name}: {e}", - exc_info=True, - ) + logger.error(f"Failed to process portal discovery for {character_name}: {e}", exc_info=True) else: - logger.warning( - f"Invalid portal message format from {websocket.client}: missing required fields" - ) + logger.warning(f"Invalid portal message format from {websocket.client}: missing required fields") continue # Unknown message types are ignored if msg_type: - logger.warning( - f"Unknown message type '{msg_type}' from {websocket.client}" - ) + logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}") finally: # Track plugin disconnection _plugin_connections = max(0, _plugin_connections - 1) - disconnected_names = [ - name for name, ws in plugin_conns.items() if ws is websocket - ] - for name in disconnected_names: - plugin_conns.pop(name, None) - live_equipment_cantrip_states.pop(name, None) - + # Clean up any plugin registrations for this socket to_remove = [n for n, ws in plugin_conns.items() if ws is websocket] for n in to_remove: # Use pop() instead of del to avoid KeyError if already removed plugin_conns.pop(n, None) - + # Also clean up any entries in the kill tracking cache for this session # Remove entries that might be associated with disconnected clients stale_keys = [] @@ -2706,27 +2190,21 @@ async def ws_receive_snapshots( stale_keys.append((session_id, char_name)) for key in stale_keys: ws_receive_snapshots._last_kills.pop(key, None) - + if to_remove: - logger.info( - f"Cleaned up plugin connections for characters: {to_remove} from {websocket.client}" - ) + logger.info(f"Cleaned up plugin connections for characters: {to_remove} from {websocket.client}") if stale_keys: - logger.debug( - f"Cleaned up {len(stale_keys)} kill tracking cache entries" - ) + logger.debug(f"Cleaned up {len(stale_keys)} kill tracking cache entries") else: logger.debug(f"No plugin registrations to clean up for {websocket.client}") - # In-memory cache of last seen kill counts per (session_id, character_name) # Used to compute deltas for updating persistent kill statistics efficiently ws_receive_snapshots._last_kills = {} - async def cleanup_stale_connections(): """Periodic cleanup of stale WebSocket connections. - + This function can be called periodically to clean up connections that may have become stale but weren't properly cleaned up. """ @@ -2735,35 +2213,32 @@ async def cleanup_stale_connections(): for char_name, ws in list(plugin_conns.items()): try: # Test if the WebSocket is still alive by checking its state - if ws.client_state.name != "CONNECTED": + if ws.client_state.name != 'CONNECTED': stale_plugins.append(char_name) except Exception: # If we can't check the state, consider it stale stale_plugins.append(char_name) - + for char_name in stale_plugins: plugin_conns.pop(char_name, None) logger.info(f"Cleaned up stale plugin connection: {char_name}") - + # Clean up browser connections stale_browsers = [] for ws in list(browser_conns): try: - if ws.client_state.name != "CONNECTED": + if ws.client_state.name != 'CONNECTED': stale_browsers.append(ws) except Exception: stale_browsers.append(ws) - + for ws in stale_browsers: browser_conns.discard(ws) - + if stale_browsers: logger.info(f"Cleaned up {len(stale_browsers)} stale browser connections") - - logger.debug( - f"Connection health check: {len(plugin_conns)} plugins, {len(browser_conns)} browsers" - ) - + + logger.debug(f"Connection health check: {len(plugin_conns)} plugins, {len(browser_conns)} browsers") @app.websocket("/ws/live") async def ws_live_updates(websocket: WebSocket): @@ -2777,10 +2252,10 @@ async def ws_live_updates(websocket: WebSocket): await websocket.accept() browser_conns.add(websocket) logger.info(f"Browser WebSocket connected: {websocket.client}") - + # Track browser connection _browser_connections += 1 - + try: while True: # Receive command messages from browser @@ -2796,11 +2271,7 @@ async def ws_live_updates(websocket: WebSocket): # New format: { player_name, command } target_name = data["player_name"] payload = data - elif ( - data.get("type") == "command" - and "character_name" in data - and "text" in data - ): + elif data.get("type") == "command" and "character_name" in data and "text" in data: # Legacy format: { type: 'command', character_name, text } target_name = data.get("character_name") payload = {"player_name": target_name, "command": data.get("text")} @@ -2812,33 +2283,25 @@ async def ws_live_updates(websocket: WebSocket): if target_ws: try: await target_ws.send_json(payload) - logger.debug( - f"Forwarded command to plugin for {target_name}: {payload}" - ) + logger.debug(f"Forwarded command to plugin for {target_name}: {payload}") except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e: logger.warning(f"Failed to forward command to {target_name}: {e}") # Remove stale connection plugin_conns.pop(target_name, None) except Exception as e: - logger.error( - f"Unexpected error forwarding command to {target_name}: {e}" - ) + logger.error(f"Unexpected error forwarding command to {target_name}: {e}") # Remove potentially corrupted connection plugin_conns.pop(target_name, None) else: - logger.warning( - f"No plugin connection found for target character: {target_name}" - ) + logger.warning(f"No plugin connection found for target character: {target_name}") except WebSocketDisconnect: pass finally: # Track browser disconnection _browser_connections = max(0, _browser_connections - 1) - + browser_conns.discard(websocket) - logger.debug( - f"Removed browser WebSocket from connection pool: {websocket.client}" - ) + logger.debug(f"Removed browser WebSocket from connection pool: {websocket.client}") ## -------------------- static frontend --------------------------- @@ -2850,9 +2313,7 @@ for route in app.routes: if isinstance(route, APIRoute): # Log the path and allowed methods for each API route logger.info(f"{route.path} -> {route.methods}") - - -# Add stats endpoint for per-character metrics + # Add stats endpoint for per-character metrics @app.get("/stats/{character_name}") async def get_stats(character_name: str): """ @@ -2882,42 +2343,32 @@ async def get_stats(character_name: str): if not row: logger.warning(f"No telemetry data found for character: {character_name}") raise HTTPException(status_code=404, detail="Character not found") - + # Extract latest snapshot data (exclude the added total_kills/total_rares) - snap_dict = { - k: v - for k, v in dict(row).items() - if k not in ("total_kills", "total_rares") - } - + snap_dict = {k: v for k, v in dict(row).items() + if k not in ("total_kills", "total_rares")} + result = { "character_name": character_name, "latest_snapshot": snap_dict, "total_kills": row["total_kills"], "total_rares": row["total_rares"], } - logger.debug( - f"Retrieved stats for character: {character_name} (optimized query)" - ) + logger.debug(f"Retrieved stats for character: {character_name} (optimized query)") return JSONResponse(content=jsonable_encoder(result)) except HTTPException: raise except Exception as e: - logger.error( - f"Failed to get stats for character {character_name}: {e}", exc_info=True - ) + logger.error(f"Failed to get stats for character {character_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") - # --- Character Stats API ------------------------------------------- - @app.post("/character-stats/test") async def test_character_stats_default(): """Inject mock character_stats data for frontend development.""" return await test_character_stats("TestCharacter") - @app.post("/character-stats/test/{name}") async def test_character_stats(name: str): """Inject mock character_stats data for a specific character name. @@ -2943,12 +2394,12 @@ async def test_character_stats(name: str): "coordination": {"base": 240, "creation": 100}, "quickness": {"base": 220, "creation": 10}, "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100}, + "self": {"base": 200, "creation": 100} }, "vitals": { "health": {"base": 341}, "stamina": {"base": 400}, - "mana": {"base": 300}, + "mana": {"base": 300} }, "skills": { "war_magic": {"base": 533, "training": "Specialized"}, @@ -2984,15 +2435,15 @@ async def test_character_stats(name: str): "sneak_attack": {"base": 10, "training": "Untrained"}, "dirty_fighting": {"base": 10, "training": "Untrained"}, "recklessness": {"base": 10, "training": "Untrained"}, - "summoning": {"base": 10, "training": "Untrained"}, + "summoning": {"base": 10, "training": "Untrained"} }, "allegiance": { "name": "Knights of Dereth", "monarch": {"name": "HighKing", "race": 1, "rank": 0, "gender": 0}, "patron": {"name": "SirLancelot", "race": 1, "rank": 5, "gender": 0}, "rank": 8, - "followers": 12, - }, + "followers": 12 + } } # Process through the same pipeline as real data @@ -3004,21 +2455,8 @@ async def test_character_stats(name: str): live_character_stats[stats_msg.character_name] = stats_dict stats_data = {} - for key in ( - "attributes", - "vitals", - "skills", - "allegiance", - "active_item_enchantments", - "race", - "gender", - "birth", - "current_title", - "skill_credits", - "burden", - "burden_units", - "encumbrance_capacity", - ): + for key in ("attributes", "vitals", "skills", "allegiance", + "race", "gender", "birth", "current_title", "skill_credits"): if stats_dict.get(key) is not None: stats_data[key] = stats_dict[key] @@ -3041,17 +2479,16 @@ async def test_character_stats(name: str): stats_data = EXCLUDED.stats_data """, { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }, - ) + "character_name": stats_msg.character_name, + "timestamp": stats_msg.timestamp, + "level": stats_msg.level, + "total_xp": stats_msg.total_xp, + "unassigned_xp": stats_msg.unassigned_xp, + "luminance_earned": stats_msg.luminance_earned, + "luminance_total": stats_msg.luminance_total, + "deaths": stats_msg.deaths, + "stats_data": json.dumps(stats_data), + }) await _broadcast_to_browser_clients(mock_data) return {"status": "ok", "character_name": stats_msg.character_name} @@ -3070,7 +2507,8 @@ async def get_character_stats(name: str): # Fall back to database row = await database.fetch_one( - "SELECT * FROM character_stats WHERE character_name = :name", {"name": name} + "SELECT * FROM character_stats WHERE character_name = :name", + {"name": name} ) if row: result = dict(row._mapping) @@ -3082,69 +2520,39 @@ async def get_character_stats(name: str): result.update(stats_data) return JSONResponse(content=jsonable_encoder(result)) - return JSONResponse( - content={"error": "No stats available for this character"}, status_code=404 - ) + return JSONResponse(content={"error": "No stats available for this character"}, status_code=404) except Exception as e: logger.error(f"Failed to get character stats for {name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/equipment-cantrip-state/{name}") -async def get_equipment_cantrip_state(name: str): - """Return latest live equipment cantrip state overlay for a character.""" - try: - data = live_equipment_cantrip_states.get(name) - if data: - return JSONResponse(content=jsonable_encoder(data)) - - return JSONResponse( - content={ - "type": "equipment_cantrip_state", - "character_name": name, - "items": [], - } - ) - except Exception as e: - logger.error( - f"Failed to get equipment cantrip state for {name}: {e}", exc_info=True - ) - raise HTTPException(status_code=500, detail="Internal server error") - - # -------------------- static frontend --------------------------- # Custom icon handler that prioritizes clean icons over originals from fastapi.responses import FileResponse - @app.get("/icons/{icon_filename}") async def serve_icon(icon_filename: str): """Serve icons from static/icons directory""" - + # Serve from static/icons directory icon_path = Path("static/icons") / icon_filename if icon_path.exists(): return FileResponse(icon_path, media_type="image/png") - + # 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.post("/inv/suitbuilder/search") async def proxy_suitbuilder_search(request: Request): """Stream suitbuilder search results - SSE requires streaming proxy.""" - inventory_service_url = os.getenv( - "INVENTORY_SERVICE_URL", "http://inventory-service:8000" - ) + inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') logger.info(f"Streaming proxy to suitbuilder search") # Read body BEFORE creating generator (request context needed) @@ -3153,22 +2561,20 @@ async def proxy_suitbuilder_search(request: Request): async def stream_response(): try: # Use streaming request with long timeout for searches - async with httpx.AsyncClient( - timeout=httpx.Timeout(300.0, connect=10.0) - ) as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client: async with client.stream( method="POST", url=f"{inventory_service_url}/suitbuilder/search", content=body, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json"} ) as response: async for chunk in response.aiter_bytes(): yield chunk except httpx.ReadTimeout: - yield b'event: error\ndata: {"message": "Search timeout"}\n\n' + yield b"event: error\ndata: {\"message\": \"Search timeout\"}\n\n" except Exception as e: logger.error(f"Streaming proxy error: {e}") - yield f'event: error\ndata: {{"message": "Proxy error: {str(e)}"}}\n\n'.encode() + yield f"event: error\ndata: {{\"message\": \"Proxy error: {str(e)}\"}}\n\n".encode() return StreamingResponse( stream_response(), @@ -3176,18 +2582,15 @@ async def proxy_suitbuilder_search(request: Request): headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Disable nginx buffering - }, + "X-Accel-Buffering": "no" # Disable nginx buffering + } ) - @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" - ) + 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 (60s timeout for large queries) @@ -3197,18 +2600,17 @@ async def proxy_inventory_service(path: str, request: Request): url=f"{inventory_service_url}/{path}", params=request.query_params, headers=dict(request.headers), - content=await request.body(), + content=await request.body() ) return Response( content=response.content, status_code=response.status_code, - headers=dict(response.headers), + 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 diff --git a/static/script.js b/static/script.js index b6192b43..a8033863 100644 --- a/static/script.js +++ b/static/script.js @@ -309,7 +309,6 @@ const chatWindows = {}; const statsWindows = {}; // Keep track of open inventory windows: character_name -> DOM element const inventoryWindows = {}; -const equipmentCantripStates = {}; /** * ---------- Application Constants ----------------------------- @@ -894,131 +893,6 @@ function updateStatsTimeRange(content, name, timeRange) { } // Show or create an inventory window for a character -/** - * Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot. - * Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... } - * Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} } - * Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... } - * - * MyWorldObject uses -1 as sentinel for "not set" on int/double properties. - */ -function normalizeInventoryItem(item) { - if (!item) return item; - if (item.name && item.item_id) return item; - - // MyWorldObject uses -1 as "not set" sentinel — filter those out - const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined; - - if (!item.item_id) item.item_id = item.Id; - if (!item.icon) item.icon = item.Icon; - if (!item.object_class) item.object_class = item.ObjectClass; - if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData; - - const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null; - const material = item.Material || null; - if (material) { - item.material = material; - item.material_name = material; - } - - // Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service - if (baseName) { - if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) { - item.name = material + ' ' + baseName; - } else { - item.name = baseName; - } - } - - const iv = item.IntValues || {}; - if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']); - if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']); - - // Container/equipment tracking - if (item.container_id === undefined) item.container_id = item.ContainerId || 0; - if (item.current_wielded_location === undefined) { - item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0; - } - if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']); - - const armor = v(item.ArmorLevel); - if (armor !== undefined) item.armor_level = armor; - - const maxDmg = v(item.MaxDamage); - if (maxDmg !== undefined) item.max_damage = maxDmg; - - const dmgBonus = v(item.DamageBonus); - if (dmgBonus !== undefined) item.damage_bonus = dmgBonus; - - const atkBonus = v(item.AttackBonus); - if (atkBonus !== undefined) item.attack_bonus = atkBonus; - - const elemDmg = v(item.ElementalDmgBonus); - if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg; - - const meleeD = v(item.MeleeDefenseBonus); - if (meleeD !== undefined) item.melee_defense_bonus = meleeD; - - const magicD = v(item.MagicDBonus); - if (magicD !== undefined) item.magic_defense_bonus = magicD; - - const missileD = v(item.MissileDBonus); - if (missileD !== undefined) item.missile_defense_bonus = missileD; - - const manaC = v(item.ManaCBonus); - if (manaC !== undefined) item.mana_conversion_bonus = manaC; - - const wieldLvl = v(item.WieldLevel); - if (wieldLvl !== undefined) item.wield_level = wieldLvl; - - const skillLvl = v(item.SkillLevel); - if (skillLvl !== undefined) item.skill_level = skillLvl; - - const loreLvl = v(item.LoreRequirement); - if (loreLvl !== undefined) item.lore_requirement = loreLvl; - - if (item.EquipSkill) item.equip_skill = item.EquipSkill; - if (item.Mastery) item.mastery = item.Mastery; - if (item.ItemSet) item.item_set = item.ItemSet; - if (item.Imbue) item.imbue = item.Imbue; - - const tinks = v(item.Tinks); - if (tinks !== undefined) item.tinks = tinks; - - const work = v(item.Workmanship); - if (work !== undefined) item.workmanship = work; - - const damR = v(item.DamRating); - if (damR !== undefined) item.damage_rating = damR; - - const critR = v(item.CritRating); - if (critR !== undefined) item.crit_rating = critR; - - const healR = v(item.HealBoostRating); - if (healR !== undefined) item.heal_boost_rating = healR; - - const vitalR = v(item.VitalityRating); - if (vitalR !== undefined) item.vitality_rating = vitalR; - - const critDmgR = v(item.CritDamRating); - if (critDmgR !== undefined) item.crit_damage_rating = critDmgR; - - const damResR = v(item.DamResistRating); - if (damResR !== undefined) item.damage_resist_rating = damResR; - - const critResR = v(item.CritResistRating); - if (critResR !== undefined) item.crit_resist_rating = critResR; - - const critDmgResR = v(item.CritDamResistRating); - if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR; - - if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) { - item.spells = item.Spells; - } - - return item; -} - /** * Create a single inventory slot DOM element from item data. * Used by both initial inventory load and live delta updates. @@ -1026,7 +900,7 @@ function normalizeInventoryItem(item) { function createInventorySlot(item) { const slot = document.createElement('div'); slot.className = 'inventory-slot'; - slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0); + slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0); // Create layered icon container const iconContainer = document.createElement('div'); @@ -1146,83 +1020,9 @@ function createInventorySlot(item) { slot.addEventListener('mouseleave', hideInventoryTooltip); slot.appendChild(iconContainer); - - // Add stack count if > 1 - const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1; - if (stackCount > 1) { - const countEl = document.createElement('div'); - countEl.className = 'inventory-count'; - countEl.textContent = stackCount; - slot.appendChild(countEl); - } - return slot; } -/** - * Equipment slots mapping for the AC inventory layout. - * Grid matches the real AC "Equipment Slots Enabled" paperdoll view. - * - * Layout (6 cols × 6 rows): - * Col: 1 2 3 4 5 6 - * Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red) - * Row 2: Trinket — ChestArmor — — Cloak - * Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt) - * Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants) - * Row 5: — Hands — LowerLegArmor — — - * Row 6: Shield — — Feet Weapon Ammo - */ -const EQUIP_SLOTS = { - // Row 1: Necklace, Head, 3× Aetheria/Sigil - 32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear - 1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear - 268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne - 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo - 1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree - - // Row 2: Trinket, Chest Armor, Cloak - 67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne - 2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor - 512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor - 134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak - - // Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt - 65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft - 4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor - 1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor - 8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor - 131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight - 2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear - - // Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants - 262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft - 32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear - 16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor - 524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight - 4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear - - // Row 5: Feet - 256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear - - // Row 6: Shield, Weapon, Ammo - 2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield - 1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon - 4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon - 16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held - 33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded - 8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition -}; - -const SLOT_COLORS = {}; -// Purple: jewelry -[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple'); -// Blue: armor -[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue'); -// Teal: clothing/misc -[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal'); -// Dark blue: weapons/combat -[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue'); - /** * Handle live inventory delta updates from WebSocket. * Updates the inventory grid for a character if their inventory window is open. @@ -1230,403 +1030,36 @@ const SLOT_COLORS = {}; function updateInventoryLive(delta) { const name = delta.character_name; const win = inventoryWindows[name]; - if (!win || !win._inventoryState) { - return; - } + if (!win) return; // No inventory window open for this character - const state = win._inventoryState; - const getItemId = (d) => { - if (d.item) return d.item.item_id || d.item.Id || d.item.id; - return d.item_id; - }; - - const itemId = getItemId(delta); + const grid = win.querySelector('.inventory-grid'); + if (!grid) return; if (delta.action === 'remove') { - state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId); - } else if (delta.action === 'add' || delta.action === 'update') { - normalizeInventoryItem(delta.item); - const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId); - if (existingIdx >= 0) { - state.items[existingIdx] = delta.item; + const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id)); + const existing = grid.querySelector(`[data-item-id="${itemId}"]`); + if (existing) existing.remove(); + } else if (delta.action === 'add') { + const newSlot = createInventorySlot(delta.item); + grid.appendChild(newSlot); + } else if (delta.action === 'update') { + const itemId = delta.item.Id || delta.item.id || delta.item.item_id; + const existing = grid.querySelector(`[data-item-id="${itemId}"]`); + if (existing) { + const newSlot = createInventorySlot(delta.item); + existing.replaceWith(newSlot); } else { - state.items.push(delta.item); + const newSlot = createInventorySlot(delta.item); + grid.appendChild(newSlot); } } - renderInventoryState(state); -} - -function renderInventoryState(state) { - // 1. Clear equipment slots - state.slotMap.forEach((slotEl) => { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot empty ${colorClass}`; - delete slotEl.dataset.itemId; - }); - - // 2. Identify containers (object_class === 10) by item_id for sidebar - // These are packs/sacks/pouches/foci that appear in inventory as items - // but should ONLY show in the pack sidebar, not in the item grid. - const containers = []; // container objects (object_class=10) - const containerItemIds = new Set(); // item_ids of containers (to exclude from grid) - - state.items.forEach(item => { - if (item.object_class === 10) { - containers.push(item); - containerItemIds.add(item.item_id); - } - }); - - // 3. Separate equipped items from pack items, excluding containers from grid - const packItems = new Map(); // container_id → [items] (non-container items only) - - // Determine the character body container_id: items with wielded_location > 0 - // share a container_id that is NOT 0 and NOT a pack's item_id. - // We treat non-wielded items from the body container as "main backpack" items. - let bodyContainerId = null; - state.items.forEach(item => { - if (item.current_wielded_location && item.current_wielded_location > 0) { - const cid = item.container_id; - if (cid && cid !== 0 && !containerItemIds.has(cid)) { - bodyContainerId = cid; - } - } - }); - - state.items.forEach(item => { - // Skip container objects — they go in sidebar only - if (containerItemIds.has(item.item_id)) return; - - if (item.current_wielded_location && item.current_wielded_location > 0) { - const mask = item.current_wielded_location; - const isArmor = item.object_class === 2; - - // For armor (object_class=2): render in ALL matching slots (multi-slot display) - // For everything else (clothing, jewelry, weapons): place in first matching slot only - if (isArmor) { - Object.keys(EQUIP_SLOTS).forEach(m => { - const slotMask = parseInt(m); - if ((mask & slotMask) === slotMask) { - const slotDef = EQUIP_SLOTS[slotMask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - } - } - } - }); - } else { - // Non-armor: find the first matching slot by exact mask key, then by bit overlap - let placed = false; - // Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly) - if (EQUIP_SLOTS[mask]) { - const slotDef = EQUIP_SLOTS[mask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - placed = true; - } - } - } - // If no exact match, find first matching bit in EQUIP_SLOTS - if (!placed) { - for (const m of Object.keys(EQUIP_SLOTS)) { - const slotMask = parseInt(m); - if ((mask & slotMask) === slotMask) { - const slotDef = EQUIP_SLOTS[slotMask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - placed = true; - break; - } - } - } - } - } - } - } else { - // Non-equipped, non-container → pack item. Group by container_id. - let cid = item.container_id || 0; - // Items on the character body (not wielded) → treat as main backpack (cid=0) - if (bodyContainerId !== null && cid === bodyContainerId) cid = 0; - if (!packItems.has(cid)) packItems.set(cid, []); - packItems.get(cid).push(item); - } - }); - - const stats = characterStats[state.characterName] || {}; - const burdenUnits = Number(stats.burden_units || 0); - const encumbranceCapacity = Number(stats.encumbrance_capacity || 0); - const burdenPct = encumbranceCapacity > 0 - ? Math.max(0, Math.min(200, (burdenUnits / encumbranceCapacity) * 100)) - : 0; - const burdenDisplay = Math.floor(burdenPct); - state.burdenLabel.textContent = `${burdenDisplay}%`; - state.burdenLabel.title = burdenUnits > 0 && encumbranceCapacity > 0 - ? `${burdenUnits.toLocaleString()} / ${encumbranceCapacity.toLocaleString()}` - : ''; - // Fill height: map 0-200% burden onto 0-100% bar height - state.burdenFill.style.height = `${burdenPct / 2}%`; - // Color by threshold - state.burdenFill.style.backgroundColor = burdenPct > 150 - ? '#b7432c' - : burdenPct > 100 - ? '#d8a431' - : '#2e8b57'; - - // 4. Sort containers for stable sidebar order (by unsigned item_id) - containers.sort((a, b) => { - const ua = a.item_id >>> 0; - const ub = b.item_id >>> 0; - return ua - ub; - }); - - // 5. Render packs in sidebar - state.packList.innerHTML = ''; - - // Helper: compute icon URL from raw icon id - const iconUrl = (iconRaw) => { - if (!iconRaw) return '/icons/06001080.png'; - const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - return `/icons/${hex}.png`; - }; - - // --- Main backpack (container_id === 0, non-containers) --- - const mainPackEl = document.createElement('div'); - mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`; - const mainPackImg = document.createElement('img'); - mainPackImg.src = '/icons/0600127E.png'; - mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; }; - - const mainFillCont = document.createElement('div'); - mainFillCont.className = 'inv-pack-fill-container'; - const mainFill = document.createElement('div'); - mainFill.className = 'inv-pack-fill'; - - // Main backpack items = container_id 0, excluding container objects - const mainPackItems = packItems.get(0) || []; - const mainPct = Math.min(100, (mainPackItems.length / 102) * 100); - mainFill.style.height = `${mainPct}%`; - - mainFillCont.appendChild(mainFill); - mainPackEl.appendChild(mainPackImg); - mainPackEl.appendChild(mainFillCont); - - mainPackEl.onclick = () => { - state.activePack = null; - renderInventoryState(state); - }; - state.packList.appendChild(mainPackEl); - - // --- Sub-packs: each container object (object_class=10) --- - containers.forEach(container => { - const cid = container.item_id; // items inside this pack have container_id = this item_id - const packEl = document.createElement('div'); - packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`; - const packImg = document.createElement('img'); - // Use the container's actual icon from the API - packImg.src = iconUrl(container.icon); - packImg.onerror = function() { this.src = '/icons/06001080.png'; }; - - const fillCont = document.createElement('div'); - fillCont.className = 'inv-pack-fill-container'; - const fill = document.createElement('div'); - fill.className = 'inv-pack-fill'; - - const pItems = packItems.get(cid) || []; - const capacity = container.items_capacity || 24; // default pack capacity in AC - const pPct = Math.min(100, (pItems.length / capacity) * 100); - fill.style.height = `${pPct}%`; - - fillCont.appendChild(fill); - packEl.appendChild(packImg); - packEl.appendChild(fillCont); - - packEl.onclick = () => { - state.activePack = cid; - renderInventoryState(state); - }; - state.packList.appendChild(packEl); - }); - - // 6. Render item grid - state.itemGrid.innerHTML = ''; - let itemsToShow = []; - if (state.activePack === null) { - // Main backpack: non-container items with container_id === 0 - itemsToShow = mainPackItems; - state.contentsHeader.textContent = 'Contents of Backpack'; - } else { - // Sub-pack: items with matching container_id - itemsToShow = packItems.get(state.activePack) || []; - // Use the container's name for the header - const activeContainer = containers.find(c => c.item_id === state.activePack); - state.contentsHeader.textContent = activeContainer - ? `Contents of ${activeContainer.name}` - : 'Contents of Pack'; + // Update item count + const countEl = win.querySelector('.inventory-count'); + if (countEl) { + const slotCount = grid.querySelectorAll('.inventory-slot').length; + countEl.textContent = `${slotCount} items`; } - - const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6); - for (let i = 0; i < numCells; i++) { - const cell = document.createElement('div'); - if (i < itemsToShow.length) { - cell.className = 'inv-item-slot occupied'; - const itemNode = createInventorySlot(itemsToShow[i]); - cell.appendChild(itemNode); - } else { - cell.className = 'inv-item-slot'; - } - state.itemGrid.appendChild(cell); - } - - renderInventoryManaPanel(state); -} - -function getManaTrackedItems(state) { - if (!state || !state.items) return []; - - const overlayItems = equipmentCantripStates[state.characterName]?.items; - const overlayMap = new Map(); - if (Array.isArray(overlayItems)) { - overlayItems.forEach(item => { - if (item && item.item_id != null) { - overlayMap.set(Number(item.item_id), item); - } - }); - } - - const snapshotMs = Date.now(); - return state.items - .filter(item => (item.current_wielded_location || 0) > 0) - .filter(item => overlayMap.has(Number(item.item_id))) - .map(item => { - const result = { ...item }; - const overlay = overlayMap.get(Number(item.item_id)) || {}; - result.mana_state = overlay.state || 'unknown'; - if (overlay.current_mana !== undefined && overlay.current_mana !== null) { - result.current_mana = overlay.current_mana; - } - if (overlay.max_mana !== undefined && overlay.max_mana !== null) { - result.max_mana = overlay.max_mana; - } - if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) { - result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds; - result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null; - } - - if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) { - const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN; - if (!Number.isNaN(snapshotUtc)) { - const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000)); - result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0); - } else { - result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds; - } - } else { - result.live_mana_time_remaining_seconds = null; - } - return result; - }) - .sort((a, b) => { - const aRemaining = a.live_mana_time_remaining_seconds; - const bRemaining = b.live_mana_time_remaining_seconds; - if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || ''); - if (aRemaining === null) return 1; - if (bRemaining === null) return -1; - if (aRemaining !== bRemaining) return aRemaining - bRemaining; - return (a.name || '').localeCompare(b.name || ''); - }); -} - -function formatManaRemaining(totalSeconds) { - if (totalSeconds === null || totalSeconds === undefined) return '--'; - const safeSeconds = Math.max(0, Math.floor(totalSeconds)); - const hours = Math.floor(safeSeconds / 3600); - const minutes = Math.floor((safeSeconds % 3600) / 60); - return `${hours}h${String(minutes).padStart(2, '0')}m`; -} - -function renderInventoryManaPanel(state) { - if (!state || !state.manaListBody || !state.manaSummary) return; - - const items = getManaTrackedItems(state); - state.manaListBody.innerHTML = ''; - - if (items.length === 0) { - const empty = document.createElement('div'); - empty.className = 'inv-mana-empty'; - empty.textContent = 'No equipped mana-bearing items'; - state.manaListBody.appendChild(empty); - state.manaSummary.textContent = 'Mana: 0 tracked'; - return; - } - - const activeCount = items.filter(item => item.mana_state === 'active').length; - const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length; - state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`; - - items.forEach(item => { - const row = document.createElement('div'); - row.className = 'inv-mana-row'; - - const iconWrap = document.createElement('div'); - iconWrap.className = 'inv-mana-icon'; - const iconSlot = createInventorySlot(item); - iconSlot.classList.add('mana-slot'); - iconWrap.appendChild(iconSlot); - - const nameEl = document.createElement('div'); - nameEl.className = 'inv-mana-name'; - nameEl.textContent = item.name || item.Name || 'Unknown Item'; - - const stateEl = document.createElement('div'); - const stateName = item.mana_state || 'unknown'; - stateEl.className = `inv-mana-state-dot mana-state-${stateName}`; - stateEl.title = stateName.replace(/_/g, ' '); - - const manaEl = document.createElement('div'); - manaEl.className = 'inv-mana-value'; - if (item.current_mana !== undefined && item.max_mana !== undefined) { - manaEl.textContent = `${item.current_mana} / ${item.max_mana}`; - } else if (item.mana_display) { - manaEl.textContent = item.mana_display; - } else { - manaEl.textContent = '--'; - } - - const timeEl = document.createElement('div'); - timeEl.className = 'inv-mana-time'; - timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds); - - row.appendChild(iconWrap); - row.appendChild(nameEl); - row.appendChild(stateEl); - row.appendChild(manaEl); - row.appendChild(timeEl); - state.manaListBody.appendChild(row); - }); - } function showInventoryWindow(name) { @@ -1645,137 +1078,19 @@ function showInventoryWindow(name) { win.dataset.character = name; inventoryWindows[name] = win; + // Loading message const loading = document.createElement('div'); loading.className = 'inventory-loading'; loading.textContent = 'Loading inventory...'; content.appendChild(loading); - win.style.width = '572px'; - win.style.height = '720px'; - + // Inventory content container const invContent = document.createElement('div'); invContent.className = 'inventory-content'; invContent.style.display = 'none'; content.appendChild(invContent); - const equipGrid = document.createElement('div'); - equipGrid.className = 'inv-equipment-grid'; - - const slotMap = new Map(); - const createdSlots = new Set(); - - Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => { - const key = `${slotDef.row}-${slotDef.col}`; - if (!createdSlots.has(key)) { - createdSlots.add(key); - const slotEl = document.createElement('div'); - const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue'; - slotEl.className = `inv-equip-slot empty ${colorClass}`; - slotEl.style.left = `${(slotDef.col - 1) * 44}px`; - slotEl.style.top = `${(slotDef.row - 1) * 44}px`; - slotEl.dataset.pos = key; - equipGrid.appendChild(slotEl); - slotMap.set(key, slotEl); - } - }); - - const sidebar = document.createElement('div'); - sidebar.className = 'inv-sidebar'; - - const manaPanel = document.createElement('div'); - manaPanel.className = 'inv-mana-panel'; - const manaHeader = document.createElement('div'); - manaHeader.className = 'inv-mana-header'; - manaHeader.textContent = 'Mana'; - const manaSummary = document.createElement('div'); - manaSummary.className = 'inv-mana-summary'; - manaSummary.textContent = 'Mana: loading'; - const manaListBody = document.createElement('div'); - manaListBody.className = 'inv-mana-list'; - manaPanel.appendChild(manaHeader); - manaPanel.appendChild(manaSummary); - manaPanel.appendChild(manaListBody); - - const burdenContainer = document.createElement('div'); - burdenContainer.className = 'inv-burden-bar'; - const burdenFill = document.createElement('div'); - burdenFill.className = 'inv-burden-fill'; - const burdenLabel = document.createElement('div'); - burdenLabel.className = 'inv-burden-label'; - burdenLabel.textContent = 'Burden'; - burdenContainer.appendChild(burdenFill); - sidebar.appendChild(burdenLabel); - sidebar.appendChild(burdenContainer); - - const packList = document.createElement('div'); - packList.className = 'inv-pack-list'; - sidebar.appendChild(packList); - - const leftColumn = document.createElement('div'); - leftColumn.className = 'inv-left-column'; - - const contentsHeader = document.createElement('div'); - contentsHeader.className = 'inv-contents-header'; - contentsHeader.textContent = 'Contents of Backpack'; - - const itemGrid = document.createElement('div'); - itemGrid.className = 'inv-item-grid'; - - leftColumn.appendChild(equipGrid); - leftColumn.appendChild(contentsHeader); - leftColumn.appendChild(itemGrid); - - invContent.appendChild(leftColumn); - invContent.appendChild(sidebar); - invContent.appendChild(manaPanel); - - const resizeGrip = document.createElement('div'); - resizeGrip.className = 'inv-resize-grip'; - win.appendChild(resizeGrip); - - let resizing = false; - let startY, startH; - - resizeGrip.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - resizing = true; - startY = e.clientY; - startH = win.offsetHeight; - document.body.style.cursor = 'ns-resize'; - document.body.style.userSelect = 'none'; - }); - - document.addEventListener('mousemove', (e) => { - if (!resizing) return; - const newH = Math.max(400, startH + (e.clientY - startY)); - win.style.height = newH + 'px'; - }); - - document.addEventListener('mouseup', () => { - if (!resizing) return; - resizing = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }); - - win._inventoryState = { - windowEl: win, - items: [], - activePack: null, - slotMap: slotMap, - equipGrid: equipGrid, - itemGrid: itemGrid, - packList: packList, - burdenFill: burdenFill, - burdenLabel: burdenLabel, - contentsHeader: contentsHeader, - manaPanel: manaPanel, - manaSummary: manaSummary, - manaListBody: manaListBody, - characterName: name - }; - + // Fetch inventory data from main app (which will proxy to inventory service) fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -1783,46 +1098,30 @@ function showInventoryWindow(name) { }) .then(data => { loading.style.display = 'none'; - invContent.style.display = 'flex'; - - data.items.forEach(i => normalizeInventoryItem(i)); - win._inventoryState.items = data.items; - - renderInventoryState(win._inventoryState); + invContent.style.display = 'block'; + + // Create inventory grid + const grid = document.createElement('div'); + grid.className = 'inventory-grid'; + + // Render each item + data.items.forEach(item => { + grid.appendChild(createInventorySlot(item)); + }); + + invContent.appendChild(grid); + + // Add item count + const count = document.createElement('div'); + count.className = 'inventory-count'; + count.textContent = `${data.item_count} items`; + invContent.appendChild(count); }) .catch(err => { handleError('Inventory', err, true); loading.textContent = `Failed to load inventory: ${err.message}`; }); - if (!characterStats[name]) { - fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - characterStats[name] = data; - if (win._inventoryState) { - renderInventoryState(win._inventoryState); - } - } - }) - .catch(() => {}); - } - - if (!equipmentCantripStates[name]) { - fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - equipmentCantripStates[name] = data; - if (win._inventoryState) { - renderInventoryState(win._inventoryState); - } - } - }) - .catch(() => {}); - } - debugLog('Inventory window created for:', name); } @@ -3039,14 +2338,6 @@ function initWebSocket() { } else if (msg.type === 'character_stats') { characterStats[msg.character_name] = msg; updateCharacterWindow(msg.character_name, msg); - if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { - renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); - } - } else if (msg.type === 'equipment_cantrip_state') { - equipmentCantripStates[msg.character_name] = msg; - if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { - renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); - } } else if (msg.type === 'inventory_delta') { updateInventoryLive(msg); } else if (msg.type === 'server_status') { diff --git a/static/style.css b/static/style.css index 4c8c7c8f..d958bc8b 100644 --- a/static/style.css +++ b/static/style.css @@ -709,25 +709,13 @@ body.noselect, body.noselect * { border-color: var(--accent); } -/* ---------- inventory window styling (AC Layout) ----------------------------- */ +/* ---------- inventory window styling ----------------------------- */ .inventory-content { flex: 1; - display: flex; - flex-direction: row; - background: none; - color: var(--ac-text); - overflow: hidden; - padding: 8px; - gap: 20px; -} - -.inv-left-column { - display: flex; - flex-direction: column; - width: 316px; - flex: none; - min-height: 0; - overflow: hidden; + padding: 15px; + background: var(--card); + color: var(--text); + overflow-y: auto; } .inventory-placeholder { @@ -745,18 +733,15 @@ body.noselect, body.noselect * { position: fixed; top: 100px; left: 400px; - width: 572px; - height: 720px; - background: rgba(20, 20, 20, 0.92); - backdrop-filter: blur(2px); - border: 2px solid var(--ac-gold); - border-radius: 4px; + width: 600px; + height: 500px; + background: var(--card); + border: 1px solid #555; + border-radius: 8px; display: flex; flex-direction: column; - box-shadow: inset 0 0 10px #000, 0 4px 15px rgba(0, 0, 0, 0.8); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); z-index: 1000; - font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; - overflow: hidden; } .inventory-loading { @@ -765,361 +750,37 @@ body.noselect, body.noselect * { justify-content: center; height: 100%; font-size: 1.1rem; - color: var(--ac-text-dim); + color: #888; } -.inv-equipment-grid { - position: relative; - width: 308px; - height: 264px; -} - -.inv-equip-slot { - position: absolute; - width: 36px; - height: 36px; - background: var(--ac-medium-stone); - border-top: 2px solid #3d4b5f; - border-left: 2px solid #3d4b5f; - border-bottom: 2px solid #12181a; - border-right: 2px solid #12181a; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -} - -.inv-equip-slot.equipped { - border: 2px solid var(--ac-cyan); - box-shadow: 0 0 5px var(--ac-cyan), inset 0 0 5px var(--ac-cyan); -} - -.inv-equip-slot.empty::before { - content: ""; - display: block; - width: 28px; - height: 28px; - background-image: url('/icons/06000133.png'); - background-size: contain; - opacity: 0.15; - filter: grayscale(100%); -} - -.inv-equip-slot .inventory-slot { - width: 100%; - height: 100%; -} - -.inv-sidebar { - width: 38px; - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - overflow: visible; - flex-shrink: 0; -} - -.inv-burden-bar { - width: 14px; - height: 40px; - background: #111; - border: 1px solid var(--ac-border-light); - position: relative; - overflow: hidden; - margin-bottom: 2px; - flex-shrink: 0; -} - -.inv-burden-fill { - width: 100%; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 0%; - transition: height 0.3s ease, background-color 0.3s ease; -} - -.inv-burden-label { - text-align: center; - font-size: 9px; - color: #ccc; - white-space: nowrap; - margin-bottom: 2px; -} - -.inv-pack-list { - display: flex; - flex-direction: column; - gap: 2px; - width: 100%; - align-items: center; - flex: 1; - min-height: 0; - overflow: visible; -} - -.inv-pack-icon { - width: 32px; - height: 32px; - position: relative; - cursor: pointer; - border: 1px solid transparent; - display: flex; - align-items: center; - justify-content: center; - background: #000; - flex-shrink: 0; - margin-right: 0; -} - -.inv-pack-icon.active { - border: 1px solid var(--ac-green); - box-shadow: 0 0 4px var(--ac-green); -} - -.inv-pack-icon.active::before { - content: "▶"; - position: absolute; - left: -14px; - top: 10px; - color: var(--ac-gold); - font-size: 12px; -} - -.inv-pack-fill-container { - position: absolute; - bottom: -6px; - left: -1px; - width: 36px; - height: 4px; - background: #000; - border: 1px solid #333; -} - -.inv-pack-fill { - height: 100%; - background: var(--ac-green); - width: 0%; -} - -.inv-pack-icon img { - width: 28px; - height: 28px; - object-fit: contain; - image-rendering: pixelated; -} - -.inv-contents-header { - color: var(--ac-gold); - font-size: 14px; - margin-bottom: 4px; - text-align: center; - border-bottom: 1px solid var(--ac-border-light); - padding-bottom: 2px; -} - -.inv-item-grid { +/* Inventory grid layout - matches AC original */ +.inventory-grid { display: grid; - grid-template-columns: repeat(6, 36px); - grid-auto-rows: 36px; - gap: 2px; - background: var(--ac-black); - padding: 4px; - border: 1px solid var(--ac-border-light); - flex: 1; + grid-template-columns: repeat(8, 36px); + gap: 0px; + padding: 8px; + background: + linear-gradient(90deg, #333 1px, transparent 1px), + linear-gradient(180deg, #333 1px, transparent 1px), + #111; + background-size: 36px 36px; + max-height: 450px; overflow-y: auto; - min-height: 0; - align-content: start; - justify-content: start; + border: 1px solid #444; } -.inv-mana-panel { - width: 162px; - min-width: 162px; - display: flex; - flex-direction: column; - background: rgba(6, 10, 18, 0.92); - border: 1px solid var(--ac-border-light); - padding: 3px; - min-height: 0; - flex-shrink: 0; - overflow: hidden; -} - -.inv-mana-header { - color: var(--ac-gold); - font-size: 14px; - text-align: center; - border-bottom: 1px solid var(--ac-border-light); - padding-bottom: 2px; -} - -.inv-mana-summary { - color: var(--ac-text-dim); - font-size: 9px; - line-height: 1.2; - padding: 2px 0; - border-bottom: 1px solid rgba(255,255,255,0.08); - margin-bottom: 3px; -} - -.inv-mana-list { - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 2px; -} - -.inv-mana-row { - display: grid; - grid-template-columns: 18px 1fr 14px; - grid-template-rows: auto auto; - gap: 1px 4px; - align-items: center; - background: rgba(18, 24, 34, 0.9); - border: 1px solid rgba(255,255,255,0.08); - padding: 1px 2px; - min-height: 20px; -} - -.inv-mana-icon { - grid-row: 1 / span 2; - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot { - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot.mana-slot { - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite { - width: 14px; - height: 14px; -} - -.inv-mana-icon .inventory-slot.mana-slot .icon-underlay, -.inv-mana-icon .inventory-slot.mana-slot .icon-base, -.inv-mana-icon .inventory-slot.mana-slot .icon-overlay { - width: 14px; - height: 14px; -} - -.inv-mana-name { - color: #f2e6c9; - font-size: 9px; - line-height: 1.05; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - grid-column: 2; - grid-row: 1; -} - -.inv-mana-value, -.inv-mana-time { - font-size: 9px; - line-height: 1.1; -} - -.inv-mana-value { - color: #98d7ff; - grid-column: 2; - grid-row: 2; -} - -.inv-mana-time { - color: #cfe6a0; - grid-column: 3; - grid-row: 2; - text-align: right; - min-width: 34px; -} - -.inv-mana-state-dot { - grid-column: 3; - grid-row: 1; - width: 10px; - height: 10px; - border-radius: 50%; - justify-self: end; - align-self: start; - background: #97a1ad; - border: 1px solid rgba(0,0,0,0.65); - box-shadow: inset 0 0 1px rgba(255,255,255,0.2); -} - -.mana-state-active { - background: #76d17f; -} - -.mana-state-not_active { - background: #ff8e6f; -} - -.mana-state-unknown { - background: #d4c27a; -} - -.mana-state-not_activatable { - background: #97a1ad; -} - -.inv-mana-empty { - color: var(--ac-text-dim); - font-size: 11px; - text-align: center; - padding: 12px 6px; -} - -.inv-item-grid::-webkit-scrollbar { - width: 12px; -} -.inv-item-grid::-webkit-scrollbar-track { - background: #0a0a0a; - border: 1px solid #333; -} -.inv-item-grid::-webkit-scrollbar-thumb { - background: #0022cc; - border-top: 2px solid var(--ac-gold); - border-bottom: 2px solid var(--ac-gold); -} - -.inv-item-slot { - width: 36px; - height: 36px; - background: #0a0a0a; - border: 1px solid #222; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -} - -.inv-item-slot.occupied { - background: linear-gradient(135deg, #3d007a 0%, #1a0033 100%); - border: 1px solid #4a148c; -} - -/* Base slot styling used by createInventorySlot */ +/* Individual inventory slots - no borders like AC original */ .inventory-slot { width: 36px; height: 36px; background: transparent; border: none; + border-radius: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; + transition: background 0.1s ease; padding: 0; margin: 0; } @@ -1133,11 +794,14 @@ body.noselect, body.noselect * { height: 36px; object-fit: contain; image-rendering: pixelated; + /* Improve icon appearance - make background match slot */ border: none; outline: none; + background: #1a1a1a; + border-radius: 2px; } -/* Icon compositing */ +/* Icon compositing for overlays/underlays - matches AC original */ .item-icon-composite { position: relative; width: 36px; @@ -1163,13 +827,24 @@ body.noselect, body.noselect * { margin: 0; } -.icon-underlay { z-index: 1; } -.icon-base { z-index: 2; } -.icon-overlay { z-index: 3; } +.icon-underlay { + z-index: 1; +} -/* Item count (hidden in new AC layout, kept for compatibility) */ +.icon-base { + z-index: 2; +} + +.icon-overlay { + z-index: 3; +} + +/* Item count */ .inventory-count { - display: none; + text-align: center; + padding: 10px; + color: #888; + font-size: 0.9rem; } /* Inventory tooltip */ @@ -1965,7 +1640,7 @@ body.noselect, body.noselect * { /* -- Tab containers (two side-by-side) -- */ .ts-tabrow { display: flex; - gap: 13px; + gap: 20px; flex-wrap: wrap; } .ts-tabcontainer { @@ -2085,7 +1760,7 @@ table.ts-props tr.ts-colnames td { padding: 6px 8px; display: flex; flex-direction: column; - gap: 8px; + gap: 4px; border-bottom: 2px solid #af7a30; } .ts-vital { @@ -2173,332 +1848,3 @@ table.ts-allegiance td:first-child { border-color: #af7a30; } - -/* ============================================== - Inventory Window Visual Fixes - AC Game Match - ============================================== */ - -.inventory-window, -.inventory-window * { - font-family: "Times New Roman", Times, serif !important; - text-shadow: 1px 1px 0 #000 !important; -} - -.inventory-window .chat-header { - background: #0e0c08 !important; - border-bottom: 1px solid #8a7a44 !important; - color: #d4af37 !important; - padding: 4px 6px !important; - box-shadow: none !important; - font-size: 11px !important; - font-weight: bold !important; - height: 22px !important; - box-sizing: border-box !important; - display: flex !important; - align-items: center !important; -} - -.inventory-window .window-content { - background: linear-gradient(180deg, #1a1814 0%, #0e0c0a 100%) !important; - border: 2px solid #8a7a44 !important; - padding: 4px !important; -} - -.inv-equipment-grid { - background: - radial-gradient(ellipse at 20% 50%, rgba(30, 28, 25, 0.6) 0%, transparent 70%), - radial-gradient(ellipse at 80% 30%, rgba(25, 23, 20, 0.4) 0%, transparent 60%), - radial-gradient(ellipse at 50% 80%, rgba(35, 30, 25, 0.5) 0%, transparent 50%), - linear-gradient(180deg, #0e0c0a 0%, #141210 50%, #0c0a08 100%) !important; -} - -.inv-equip-slot { - width: 36px !important; - height: 36px !important; - border-top: 1px solid #2a2a30 !important; - border-left: 1px solid #2a2a30 !important; - border-bottom: 1px solid #0a0a0e !important; - border-right: 1px solid #0a0a0e !important; - background: #14141a !important; -} - -.inv-equip-slot.equipped { - border: 1px solid #222 !important; - background: #14141a !important; - box-shadow: none !important; -} - -/* Equipment slot color categories - matching real AC - Real AC uses clearly visible colored borders AND tinted backgrounds per slot type */ -.inv-equip-slot.slot-purple { - border: 1px solid #8040a8 !important; - background: #2a1538 !important; -} -.inv-equip-slot.slot-blue { - border: 1px solid #3060b0 !important; - background: #141e38 !important; -} -.inv-equip-slot.slot-teal { - border: 1px solid #309898 !important; - background: #0e2828 !important; -} -.inv-equip-slot.slot-darkblue { - border: 1px solid #1e3060 !important; - background: #0e1428 !important; -} -/* Brighter tint when equipped (item present) */ -.inv-equip-slot.equipped.slot-purple { - border: 1px solid #9050b8 !important; - background: #341a44 !important; -} -.inv-equip-slot.equipped.slot-blue { - border: 1px solid #4070c0 !important; - background: #1a2844 !important; -} -.inv-equip-slot.equipped.slot-teal { - border: 1px solid #40a8a8 !important; - background: #143030 !important; -} -.inv-equip-slot.equipped.slot-darkblue { - border: 1px solid #283870 !important; - background: #141a30 !important; -} - -.inv-equip-slot.empty::before { - opacity: 0.15 !important; - filter: grayscale(100%) !important; -} - -.inv-item-grid { - background: #1a1208 !important; - gap: 2px !important; -} - -.inv-item-slot.occupied { - background: #442c1e !important; - border: 1px solid #5a3c28 !important; -} - -.inv-item-slot { - background: #2a1c14 !important; - border: 1px solid #3a2818 !important; -} - -.inv-contents-header { - font-size: 10px !important; - font-family: "Times New Roman", Times, serif !important; - color: #ffffff !important; - border-bottom: none !important; - text-align: center !important; - padding-bottom: 2px !important; - margin-bottom: 2px !important; - text-transform: none !important; - letter-spacing: 0 !important; -} - -.inventory-content { - gap: 13px !important; -} - -.inv-left-column { - width: 316px !important; - flex: none !important; -} - -.inv-sidebar { - width: 38px !important; - align-items: center !important; -} - -.inv-pack-icon { - width: 32px !important; - height: 32px !important; - border: 1px solid #1a1a1a !important; - margin-bottom: 2px !important; - overflow: visible !important; - margin-right: 8px !important; -} - -.inv-pack-icon img { - width: 28px !important; - height: 28px !important; -} - -.inv-pack-icon.active { - border: 1px solid #8a7a44 !important; - position: relative !important; - box-shadow: none !important; -} - -.inv-pack-icon.active::before { - content: '' !important; - position: absolute !important; - left: -8px !important; - top: 50% !important; - transform: translateY(-50%) !important; - width: 0 !important; - height: 0 !important; - border-top: 6px solid transparent !important; - border-bottom: 6px solid transparent !important; - border-left: 7px solid #d4af37 !important; - display: block !important; -} - -.inv-pack-fill-container { - position: absolute !important; - right: -6px !important; - top: 0 !important; - bottom: auto !important; - left: auto !important; - width: 4px !important; - height: 32px !important; - background: #000 !important; - border: 1px solid #333 !important; - display: flex !important; - flex-direction: column-reverse !important; -} - -.inv-pack-fill { - width: 100% !important; - background: #00ff00 !important; - transition: height 0.3s ease !important; -} - -.inv-item-grid::-webkit-scrollbar { - width: 14px; -} -.inv-item-grid::-webkit-scrollbar-track { - background: #0e0a04; - border: 1px solid #8a7a44; -} -.inv-item-grid::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, #2244aa 0%, #1a3399 50%, #2244aa 100%); - border: 1px solid #8a7a44; -} -.inv-item-grid::-webkit-scrollbar-button:vertical:start:decrement, -.inv-item-grid::-webkit-scrollbar-button:vertical:end:increment { - background: #8a2020; - border: 1px solid #b89a30; - height: 14px; - display: block; -} - - - -.inventory-count { - display: block !important; - position: absolute; - top: 1px; - right: 1px; - bottom: auto; - left: auto; - font-size: 8px !important; - color: #fff !important; - background: #1a3399 !important; - padding: 0 2px !important; - line-height: 12px !important; - min-width: 8px !important; - text-align: center !important; - pointer-events: none; - z-index: 10; - text-shadow: none !important; -} - -.inventory-window { - border: 2px solid #8a7a44 !important; - background: #0e0c08 !important; - resize: none !important; - width: 572px !important; - min-height: 720px !important; -} - -.inv-mana-panel { - width: 162px !important; - min-width: 162px !important; - background: #111014 !important; - border: 1px solid #5a4a24 !important; - overflow: hidden !important; -} - -.inv-mana-header { - font-size: 10px !important; - color: #ffffff !important; - border-bottom: none !important; - padding-bottom: 2px !important; -} - -.inv-mana-summary { - font-size: 9px !important; - color: #d4af37 !important; -} - -.inv-mana-row { - grid-template-columns: 18px 1fr 14px !important; - grid-template-rows: auto auto !important; - gap: 1px 4px !important; - padding: 1px 2px !important; - background: #1a1208 !important; - border: 1px solid #3a2818 !important; -} - -.inv-mana-icon { - grid-row: 1 / span 2 !important; - width: 16px !important; - height: 16px !important; -} - -.inv-mana-icon .inventory-slot { - width: 16px !important; - height: 16px !important; -} - -.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite, -.inv-mana-icon .inventory-slot.mana-slot .icon-underlay, -.inv-mana-icon .inventory-slot.mana-slot .icon-base, -.inv-mana-icon .inventory-slot.mana-slot .icon-overlay { - width: 14px !important; - height: 14px !important; -} - -.inv-mana-name { - font-size: 9px !important; - line-height: 1.05 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; -} - -.inv-mana-value, -.inv-mana-time { - font-size: 9px !important; -} - -.inv-mana-state-dot { - width: 10px !important; - height: 10px !important; -} - -/* Custom resize grip for inventory window */ -.inv-resize-grip { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 6px; - cursor: ns-resize; - z-index: 100; - background: transparent; - border-top: 1px solid #8a7a44; -} - -.inv-resize-grip::after { - content: ''; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: 30px; - height: 2px; - border-top: 1px solid #5a4a24; - border-bottom: 1px solid #5a4a24; -} diff --git a/static/suitbuilder.html b/static/suitbuilder.html index 5ede36cc..a860a272 100644 --- a/static/suitbuilder.html +++ b/static/suitbuilder.html @@ -206,14 +206,6 @@ -
- - -
-
- - -
diff --git a/static/suitbuilder.js b/static/suitbuilder.js index f523134b..2954443d 100644 --- a/static/suitbuilder.js +++ b/static/suitbuilder.js @@ -29,8 +29,6 @@ const COMMON_CANTRIPS = [ 'Legendary Creature Enchantment Aptitude', 'Legendary Item Enchantment Aptitude', 'Legendary Life Magic Aptitude', - 'Legendary Healing Prowess', - 'Legendary Summoning Prowess', // Defense 'Legendary Magic Resistance', 'Legendary Invulnerability',