Debug and inventory

This commit is contained in:
erik 2025-06-19 17:46:19 +00:00
parent 1febf6e918
commit 80a0a16bab
15 changed files with 2764 additions and 341 deletions

View file

@ -1,95 +0,0 @@
# Project Architecture and Data Model
This document provides an overview of the project files, their roles, and a detailed description of the database architecture and data model.
## Project Structure
Root directory:
- **Dockerfile**: Defines the Python 3.12-slim image, installs dependencies (FastAPI, Uvicorn, SQLAlchemy, databases, TimescaleDB support), and runs the app.
- **docker-compose.yml**: Orchestrates two services:
- **dereth-tracker**: The FastAPI application container.
- **db**: A TimescaleDB (PostgreSQL 14 + TimescaleDB extension) container for persistent storage.
- **README.md**: High-level documentation and usage instructions.
- **EVENT_FORMATS.json**: Example JSON payloads for all event types (`telemetry`, `spawn`, `chat`, `rare`).
- **db.py**: Legacy SQLite-based storage (telemetry_log & live_state tables, WAL mode, auto-vacuum).
- **db_async.py**: Async database definitions for PostgreSQL/TimescaleDB:
- Table schemas (SQLAlchemy Core):
- `telemetry_events`,
- `char_stats`,
- `rare_stats`,
- `rare_stats_sessions`,
- `spawn_events`,
- `rare_events`.
- `init_db_async()`: Creates tables, enables TimescaleDB extension, and configures a hypertable on `telemetry_events`.
- **main.py**: The FastAPI application:
- HTTP endpoints: `/debug`, `/live`, `/history`, `/trails`.
- WebSocket endpoints: `/ws/position` (plugin data in), `/ws/live` (browser live updates).
- Pydantic models: `TelemetrySnapshot`, `SpawnEvent`.
- In-memory state: `live_snapshots`, WebSocket connection registries.
- **generate_data.py**: Sample WebSocket client that sends synthetic telemetry snapshots.
- **alembic/** & **alembic.ini**: Migration tooling for evolving the database schema.
- **static/**: Frontend assets (HTML, CSS, JavaScript, images) for the live map UI.
- **FIXES.md**, **LESSONSLEARNED.md**, **TODO.md**: Project notes and future work.
## Database Overview
### Technology Stack
- **PostgreSQL** 14 with **TimescaleDB** extension for time-series optimization.
- **databases** library (async) with **SQLAlchemy Core** for schema definitions and queries.
- Environment variable: `DATABASE_URL` controls the connection string.
### Tables and Hypertable
1. **telemetry_events** (hypertable)
- Columns:
- `id`: Integer, primary key.
- `character_name` (String), `char_tag` (String, nullable), `session_id` (String, indexed).
- `timestamp` (DateTime with TZ, indexed) — partitioning column for the hypertable.
- `ew`, `ns`, `z`: Float coordinates.
- `kills`, `deaths`, `rares_found`, `prismatic_taper_count`: Integer metrics.
- `kills_per_hour`, `onlinetime` (String), `vt_state` (String).
- Optional: `mem_mb`, `cpu_pct`, `mem_handles`, `latency_ms`.
- Created via `SELECT create_hypertable('telemetry_events', 'timestamp', if_not_exists=>true, create_default_indexes=>false)`.
2. **char_stats**
- Tracks cumulative kills per character.
- Columns: `character_name` (PK), `total_kills` (Integer).
3. **rare_stats**
- Tracks total rare spawns per character.
- Columns: `character_name` (PK), `total_rares` (Integer).
4. **rare_stats_sessions**
- Tracks rarities per session.
- Columns: composite PK `(character_name, session_id)`, `session_rares` (Integer).
5. **spawn_events**
- Records individual mob spawn events for heatmapping.
- Columns: `id` (PK), `character_name` (String), `mob` (String), `timestamp` (DateTime), `ew`, `ns`, `z` (Float).
- Coordinates (`ew`, `ns`, `z`) can be sent as JSON numbers or strings and are coerced to floats.
6. **rare_events**
- Records each rare spawn event for future heatmaps and analysis.
- Columns: `id` (PK), `character_name` (String), `name` (String), `timestamp` (DateTime), `ew`, `ns`, `z` (Float).
### Initialization and Migrations
- On startup (`main.py`), `init_db_async()` is called:
1. Creates all tables via SQLAlchemys `metadata.create_all()`.
2. Enables TimescaleDB extension.
3. Converts `telemetry_events` to a hypertable, skipping default index creation to avoid PK/index collisions.
- Alembic is configured for schema migrations (`alembic/` directory).
## Data Ingestion Flow
1. **Plugin** connects to `/ws/position` with a shared secret.
2. Sends JSON frames of types:
- `telemetry`: parsed into `TelemetrySnapshot`, upserted into `live_snapshots`, persisted to `telemetry_events`, and broadcast to browser clients.
- `spawn`: parsed into `SpawnEvent`, inserted into `spawn_events`.
- `rare`: increments `rare_stats` and `rare_stats_sessions` via upsert operations.
- `chat`: broadcast to browser clients without DB writes.
3. **Browser** connects to `/ws/live` to receive live updates and can send commands to plugins.
## HTTP Query Endpoints
- **GET /live**: returns recent snapshots (last 30s) plus rare counts per character.
- **GET /history**: returns ordered telemetry history with optional time filters.
- **GET /trails**: returns positional trails for a lookback window.
This architecture enables real-time telemetry ingestion, historical time-series analysis, and an interactive front-end map for tracking players and spawn events.

View file

@ -1,45 +0,0 @@
{
"_comment": "These are individual example payloads keyed by event type. Send each payload separately over the WS connection.",
"telemetry": {
"type": "telemetry",
"character_name": "string",
"session_id": "string",
"timestamp": "2025-04-22T13:45:00Z",
"ew": 123.4,
"ns": 567.8,
"z": 10.2,
"kills": 42,
"kills_per_hour": 7.0,
"onlinetime": "00.05:00",
"deaths": 1,
"prismatic_taper_count": 17,
"vt_state": "Combat",
"mem_mb": 256.5,
"cpu_pct": 12.3,
"mem_handles": 1024
},
"spawn": {
"type": "spawn",
"timestamp": "2025-04-22T13:46:00Z",
"character_name": "MyCharacter",
"mob": "Forest Troll",
"ew": 100.1,
"ns": 200.2
},
"chat": {
"type": "chat",
"timestamp": "2025-04-22T13:47:00Z",
"character_name": "MyCharacter",
"text": "Hello world!",
"color": "#88FF00"
},
"rare": {
"type": "rare",
"timestamp": "2025-04-22T13:48:00Z",
"character_name": "MyCharacter",
"name": "Golden Gryphon",
"ew": 150.5,
"ns": 350.7,
"z": 5.0
}
}

View file

@ -1,48 +0,0 @@
# Planned Fixes and Enhancements
_This document captures the next set of improvements and fixes for Dereth Tracker._
## 1. Chat Window Styling and Format
- **Terminal-style chat interface**
- Redesign the chat window to mimic Asherons Call in-game chat: monospaced font, dark semi-transparent background, and text entry at the bottom.
- Implement timestamped message prefixes (e.g., `[12:34] character: message`).
- Support command- and system-level styling (e.g., whispers, party chat) with distinct color cues.
## 2. Incoming Message Parsing
- **Strip protocol overhead**
- Remove JSON envelope artifacts (e.g., remove quotes, braces) so only raw message text appears.
- Validate and sanitize incoming payloads (e.g., escape HTML, truncate length).
- Optionally support rich-text / emotes by parsing simple markup (e.g., `*bold*`, `/me action`).
## 3. Message Color Scheme
- **Per-character consistent colors**
- Map each character name to a unique, but legible, pastel or muted color.
- Ensure sufficient contrast with the chat background (WCAG AA compliance).
- Provide user override settings for theme (light/dark) and custom palettes.
## 4. Command Prompt Integration
- **Client-side command entry**
- Allow slash-commands in chat input (e.g., `/kick PlayerName`, `/whisper PlayerName Hello`).
- Validate commands before sending to `/ws/live` and route to the correct plugin WebSocket.
- Show feedback on command success/failure in the chat window.
## 5. Security Hardening
- **Authentication & Authorization**
- Enforce TLS (HTTPS/WSS) for all HTTP and WebSocket connections.
- Protect `/ws/position` with rotating shared secrets or token-based auth (e.g., JWT).
- Rate-limit incoming telemetry and chat messages to prevent flooding.
- Sanitize all inputs to guard against injection (SQL, XSS) and implement strict CSP headers.
## 6. Performance and Scalability
- **Throttling and Load Handling**
- Batch updates during high-frequency telemetry bursts to reduce WebSocket churn.
- Cache recent `/live` and `/trails` responses in-memory to relieve SQLite under load.
- Plan for horizontal scaling: stateless FastAPI behind a load balancer with shared database or in-memory pub/sub.
## 7. Testing and Quality Assurance
- **Automated Tests**
- Unit tests for `db.save_snapshot`, HTTP endpoints, and WebSocket handlers.
- E2E tests for the frontend UI (using Puppeteer or Playwright) to verify chat and map functionality.
- Security regression tests for input sanitization and auth enforcement.
_Refer to this list when planning next development sprints. Each item should be broken down into individual tickets or pull requests._

View file

@ -1,38 +0,0 @@
# Lessons Learned
_This document captures the key takeaways and implementation details from today's troubleshooting session._
## 1. API Routing & Proxy Configuration
- **API_BASE constant**: The frontend (`static/script.js`) uses a base path `API_BASE` (default `/api`) to prefix all HTTP and WebSocket calls. Always update this to match your proxy mount point.
- **Nginx WebSocket forwarding**: To proxy WebSockets, you must forward the `Upgrade` and `Connection` headers:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
Without these, the WS handshake downgrades to a normal HTTP GET, resulting in 404s.
## 2. Debugging WebSocket Traffic
- Logged all incoming WS frames in `main.py`:
- `[WS-PLUGIN RX] <client>: <raw>` for messages on `/ws/position`
- `[WS-LIVE RX] <client>: <parsed-json>` for messages on `/ws/live`
- These prints surface registration, telemetry, chat, and command packets, aiding root-cause analysis.
## 3. Data Serialization Fix
- Python `datetime` objects are not JSON-serializable by default. We wrapped outbound payloads via FastAPIs `jsonable_encoder` in `_broadcast_to_browser_clients` so that:
```python
data = jsonable_encoder(snapshot)
await ws.send_json(data)
```
This ensures ISO8601 strings for timestamps and eliminates `TypeError: Object of type datetime is not JSON serializable`.
## 4. Frontend Adjustments
- **Chat input positioning**: Moved the `.chat-form` to `position: absolute; bottom: 0;` so the input always sticks to the bottom of its window.
- **Text color**: Forced the input text and placeholder to white (`.chat-input, .chat-input::placeholder { color: #fff; }`) and forcibly set all incoming messages to white via `.chat-messages div { color: #fff !important; }`.
- **Padding for messages**: Added `padding-bottom` to `.chat-messages` to avoid new messages being hidden behind the fixed input bar.
## 5. General Best Practices
- Clear browser cache after updating static assets to avoid stale JS/CSS.
- Keep patches targeted: fix the source of issues (e.g., JSON encoding or missing headers) rather than applying superficial workarounds.
- Use consistent CSS variables for theming (e.g., `--text`, `--bg-main`).
By consolidating these lessons, we can onboard faster next time and avoid repeating these pitfalls.

View file

@ -1,6 +1,6 @@
# Dereth Tracker # Dereth Tracker
Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, and provides a live map interface along with a sample data generator for testing. Dereth Tracker is a real-time telemetry service for the world of Dereth. It collects player data, stores it in a PostgreSQL (TimescaleDB) database for efficient time-series storage, provides a live map interface, and includes a comprehensive inventory management system for tracking and searching character equipment.
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
@ -16,20 +16,27 @@ Dereth Tracker is a real-time telemetry service for the world of Dereth. It coll
## Overview ## Overview
- This project provides: This project provides:
- A FastAPI backend with endpoints for receiving and querying telemetry data. - A FastAPI backend with endpoints for receiving and querying telemetry data.
- PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats. - PostgreSQL/TimescaleDB-based storage for time-series telemetry and per-character stats.
- A live, interactive map using static HTML, CSS, and JavaScript. - A live, interactive map using static HTML, CSS, and JavaScript.
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots. - A comprehensive inventory management system with search capabilities.
- Real-time inventory updates via WebSocket when characters log in/out.
- A sample data generator script (`generate_data.py`) for simulating telemetry snapshots. - A sample data generator script (`generate_data.py`) for simulating telemetry snapshots.
## Features ## Features
- **WebSocket /ws/position**: Stream telemetry snapshots (protected by a shared secret). - **WebSocket /ws/position**: Stream telemetry snapshots and inventory updates (protected by a shared secret).
- **GET /live**: Fetch active players seen in the last 30 seconds. - **GET /live**: Fetch active players seen in the last 30 seconds.
- **GET /history**: Retrieve historical telemetry data with optional time filtering. - **GET /history**: Retrieve historical telemetry data with optional time filtering.
- **GET /debug**: Health check endpoint. - **GET /debug**: Health check endpoint.
- **Live Map**: Interactive map interface with panning, zooming, and sorting. - **Live Map**: Interactive map interface with panning, zooming, and sorting.
- **Inventory Management**:
- Real-time inventory updates via WebSocket on character login/logout
- Advanced search across all character inventories
- Filter by character, equipment type, material, stats, and more
- Sort by any column with live results
- Track item properties including spells, armor level, damage ratings
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing. - **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
## Requirements ## Requirements
@ -218,11 +225,13 @@ For a complete reference of JSON payloads accepted by the backend (over `/ws/pos
- **Spawn events** (`type`: "spawn") - **Spawn events** (`type`: "spawn")
- **Chat events** (`type`: "chat") - **Chat events** (`type`: "chat")
- **Rare events** (`type`: "rare") - **Rare events** (`type`: "rare")
- **Inventory events** (`type`: "inventory")
Notes on payload changes: Notes on payload changes:
- Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0. - Spawn events no longer require the `z` coordinate; if omitted, the server defaults it to 0.0.
Coordinates (`ew`, `ns`, `z`) may be sent as JSON numbers or strings; the backend will coerce them to floats. Coordinates (`ew`, `ns`, `z`) may be sent as JSON numbers or strings; the backend will coerce them to floats.
- Telemetry events have removed the `latency_ms` field; please omit it from your payloads. - Telemetry events have removed the `latency_ms` field; please omit it from your payloads.
- Inventory events are sent automatically on character login/logout containing complete inventory data.
Each entry shows all required and optional fields, their types, and example values. Each entry shows all required and optional fields, their types, and example values.
@ -253,11 +262,14 @@ Response:
## Frontend ## Frontend
- **Live Map**: `static/index.html` Real-time player positions on a map. - **Live Map**: `static/index.html` Real-time player positions on a map.
- **Inventory Search**: `static/inventory.html` Search and browse character inventories with advanced filtering.
## Database Schema ## Database Schema
This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data This service uses PostgreSQL with the TimescaleDB extension to store telemetry time-series data,
and aggregate character statistics. The primary tables are: aggregate character statistics, and a separate inventory database for equipment management.
### Telemetry Database Tables:
- **telemetry_events** (hypertable): - **telemetry_events** (hypertable):
- `id` (PK, serial) - `id` (PK, serial)
@ -297,6 +309,41 @@ and aggregate character statistics. The primary tables are:
- `timestamp` (timestamptz) - `timestamp` (timestamptz)
- `ew`, `ns`, `z` (float) - `ew`, `ns`, `z` (float)
### Inventory Database Tables:
- **items**:
- `id` (PK, serial)
- `character_name` (text, indexed)
- `item_id` (bigint)
- `name` (text)
- `object_class` (integer)
- `icon`, `value`, `burden` (integer)
- `current_wielded_location`, `bonded`, `attuned`, `unique` (various)
- `timestamp` (timestamptz)
- **item_combat_stats**:
- `item_id` (FK to items.id)
- `armor_level`, `max_damage` (integer)
- `damage_bonus`, `attack_bonus` (float)
- Various defense bonuses
- **item_enhancements**:
- `item_id` (FK to items.id)
- `material` (varchar)
- `item_set` (varchar)
- `tinks`, `workmanship` (integer/float)
- **item_spells**:
- `item_id` (FK to items.id)
- `spell_id` (integer)
- `spell_name` (text)
- `is_legendary`, `is_epic` (boolean)
- **item_raw_data**:
- `item_id` (FK to items.id)
- `int_values`, `double_values`, `string_values`, `bool_values` (JSONB)
- `original_json` (JSONB)
## Contributing ## Contributing
Contributions are welcome! Feel free to open issues or submit pull requests. Contributions are welcome! Feel free to open issues or submit pull requests.

62
TODO.md
View file

@ -1,62 +0,0 @@
## TODO: Migration & Parity Plan
### Detailed Plan
1. [ ] Review Repository for Data Storage and Event Handling
- [ ] Scan for SQLite usage (telemetry, spawns, chat, session data)
- [ ] Identify all event ingestion code paths (WebSocket, HTTP, direct DB inserts)
- [ ] Locate old or deprecated payload handling
2. [ ] Update Database Access Layer to PostgreSQL/TimescaleDB
- [ ] Replace SQLite code with SQLAlchemy models & Alembic migrations
- [ ] Configure TimescaleDB hypertable for telemetry data
- [ ] Create migration for spawn events table
- [ ] Set up `DATABASE_URL` and (optional) local Docker Compose service
3. [ ] Refactor Event Ingestion Endpoints and Logic
- [ ] Modify `/ws/position` to accept new schemas (telemetry, spawn, chat)
- [ ] Persist telemetry and spawn events to PostgreSQL
- [ ] Continue broadcasting chat messages without persisting
4. [ ] Update Data Models and API Response Types
- [ ] Align Pydantic schemas to new event payload structures
- [ ] Update `/live`, `/history`, `/trails` to query Postgres
- [ ] Optionally add `GET /spawns` endpoint for spawn data
5. [ ] Migrate or Clean Historical Data
- [ ] If needed, write script to migrate existing SQLite data to Postgres
- [ ] Otherwise remove old migration and data transformation code
6. [ ] Refactor Frontend to Query and Visualize New Data (deferred)
7. [ ] Add or Update Grafana Dashboards (deferred)
8. [ ] Testing & Verification (deferred)
9. [ ] Documentation & Developer Instructions
- [ ] Update README and docs for PostgreSQL/TimescaleDB setup
10. [ ] Maintenance and Future Enhancements
- [ ] Document data retention and aggregation policies for TimescaleDB
### Phases
### Phase 1: Core Migration & Parity
- [ ] Remove SQLite usage and associated code (`db.py` and direct `sqlite3` calls).
- [ ] Integrate PostgreSQL/TimescaleDB using SQLAlchemy and Alembic for migrations.
- Set up `DATABASE_URL` environment variable for connection.
- (Optional) Add a TimescaleDB service in `docker-compose.yml` for local development.
- [ ] Define SQLAlchemy models and create initial Alembic migration:
- Telemetry table as a TimescaleDB hypertable.
- Spawn events table.
- [ ] Update backend (`main.py`):
- Ingest `telemetry` and new `spawn` messages from `/ws/position` WebSocket.
- Persist telemetry and spawn events to PostgreSQL.
- Continue broadcasting `chat` messages without persisting.
- [ ] Ensure existing endpoints (`/live`, `/history`, `/trails`) operate against the new database.
- [ ] (Optional) Add retrieval endpoint for spawn events (e.g., `GET /spawns`).
### Phase 2: Frontend & Visualization
- [ ] Update frontend to display spawn events (markers or lists).
- [ ] Expose new telemetry metrics in the UI: `latency_ms`, `mem_mb`, `cpu_pct`, `mem_handles`.
### Phase 3: Dashboards & Monitoring
* [ ] Provision or update Grafana dashboards for:
- Telemetry performance (TimescaleDB queries, hypertable metrics).
- Spawn event heatmaps and trends.
- Rare event heatmaps and trends.
### Phase 4: Documentation & Maintenance
- [ ] Finalize README and developer docs with PostgreSQL setup, migration steps, and usage examples.
- [ ] Document how to add new event types or payload fields, including schema, migrations, and tests.
- [ ] Establish data retention and aggregation policies for TimescaleDB hypertables.

View file

@ -254,6 +254,18 @@ def translate_item_type(item_type_id: int) -> str:
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}") 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:
"""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', {})
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_object_class(object_class_id: int, item_data: dict = None) -> str: 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.""" """Translate object class ID to human-readable name with context-aware detection."""
# Use the extracted ObjectClass enum first # Use the extracted ObjectClass enum first
@ -573,6 +585,17 @@ def translate_equipment_slot(wielded_location: int) -> str:
} }
return name_mapping.get(slot_name, slot_name) 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"
}
if wielded_location in common_slots:
return common_slots[wielded_location]
# If no exact match, decode bit flags # If no exact match, decode bit flags
slot_parts = [] slot_parts = []
for mask_value, slot_name in equip_mask_map.items(): for mask_value, slot_name in equip_mask_map.items():
@ -627,7 +650,7 @@ def translate_equipment_slot(wielded_location: int) -> str:
else: else:
return f"Special Slot ({wielded_location})" return f"Special Slot ({wielded_location})"
return f"Unknown Slot ({wielded_location})" return "-"
def translate_workmanship(workmanship_value: int) -> str: def translate_workmanship(workmanship_value: int) -> str:
"""Translate workmanship value to descriptive text.""" """Translate workmanship value to descriptive text."""
@ -845,10 +868,21 @@ def get_comprehensive_translations(item_data: Dict[str, Any]) -> Dict[str, Any]:
translations['material_name'] = translate_material_type(material_id) translations['material_name'] = translate_material_type(material_id)
translations['material_id'] = material_id translations['material_id'] = material_id
# Translate item type if present # 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', {})
if isinstance(int_values, dict):
item_type_id = int_values.get('1', int_values.get(1))
if item_type_id is not None: 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')
if object_class is not None:
translations['item_type_name'] = derive_item_type_from_object_class(object_class, item_data)
# Translate object class using WeenieType enum # Translate object class using WeenieType enum
object_class = item_data.get('ObjectClass') object_class = item_data.get('ObjectClass')
@ -880,7 +914,7 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]:
# Item state # Item state
'bonded': int_values.get('33', int_values.get(33, 0)), 'bonded': int_values.get('33', int_values.get(33, 0)),
'attuned': int_values.get('114', int_values.get(114, 0)), 'attuned': int_values.get('114', int_values.get(114, 0)),
'unique': int_values.get('279', int_values.get(279, 0)) != 0, 'unique': bool(int_values.get('279', int_values.get(279, 0))),
# Stack/Container properties # Stack/Container properties
'stack_size': int_values.get('12', int_values.get(12, 1)), 'stack_size': int_values.get('12', int_values.get(12, 1)),
@ -941,11 +975,11 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]:
'equip_skill': item_data.get('EquipSkill'), 'equip_skill': item_data.get('EquipSkill'),
}, },
'enhancements': { 'enhancements': {
'material': item_data.get('Material'), 'material': None, # Will be set below with proper logic
'imbue': item_data.get('Imbue'), 'imbue': item_data.get('Imbue'),
'tinks': item_data.get('Tinks', -1), 'tinks': item_data.get('Tinks', -1),
'workmanship': item_data.get('Workmanship', -1.0), 'workmanship': item_data.get('Workmanship', -1.0),
'item_set': int_values.get('265', int_values.get(265)) if int_values.get('265', int_values.get(265)) else None, 'item_set': None, # Will be set below with translation
# Advanced tinkering # Advanced tinkering
'num_times_tinkered': int_values.get('171', int_values.get(171, -1)), 'num_times_tinkered': int_values.get('171', int_values.get(171, -1)),
@ -997,6 +1031,32 @@ def extract_item_properties(item_data: Dict[str, Any]) -> Dict[str, Any]:
} }
} }
# Handle material field properly - check if already translated or needs translation
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
else:
# Material needs translation from IntValues[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
# Translate item_set ID to name for database storage
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', {})
set_name = attribute_set_info.get(str(item_set_id))
if 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)
# Get comprehensive translations # Get comprehensive translations
translations = get_comprehensive_translations(item_data) translations = get_comprehensive_translations(item_data)
if translations: if translations:
@ -1113,6 +1173,14 @@ async def process_inventory(inventory: InventoryItem):
# Simple INSERT since we cleared the table first # 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']}")
item_stmt = sa.insert(Item).values( item_stmt = sa.insert(Item).values(
character_name=inventory.character_name, character_name=inventory.character_name,
item_id=item_id, item_id=item_id,
@ -1316,9 +1384,12 @@ async def get_character_inventory(
# Add translated properties to the item # Add translated properties to the item
processed_item['translated_properties'] = properties processed_item['translated_properties'] = properties
# Add material translation # Add material name - use material directly if it's already a string
if processed_item.get('material'): if processed_item.get('material'):
processed_item['material_name'] = translate_material_type(processed_item['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 # Add object class translation
if processed_item.get('object_class'): if processed_item.get('object_class'):
@ -1430,6 +1501,8 @@ async def get_character_inventory(
processed_item['material_name'] = trans['material_name'] processed_item['material_name'] = trans['material_name']
if trans.get('material_id'): if trans.get('material_id'):
processed_item['material_id'] = trans['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 # Continue with other enhancements
if 'enhancements' in translated_props: if 'enhancements' in translated_props:
@ -2092,11 +2165,15 @@ async def search_items(
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 material_name = None
if item.get('material'): if item.get('material'):
material_name = translate_material_type(item['material']) # Check if material is already a string or needs translation
if isinstance(item['material'], str):
material_name = item['material']
else:
material_name = translate_material_type(item['material'])
elif properties.get('translations', {}).get('material_name'): elif properties.get('translations', {}).get('material_name'):
material_name = properties['translations']['material_name'] material_name = properties['translations']['material_name']
if material_name: if material_name and not material_name.startswith('Unknown_Material_'):
item['material_name'] = material_name item['material_name'] = material_name
# Apply material prefix to item name # Apply material prefix to item name
original_name = item['name'] original_name = item['name']
@ -2108,6 +2185,13 @@ async def search_items(
if item.get('object_class'): if item.get('object_class'):
item['object_class_name'] = translate_object_class(item['object_class'], original_json) 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'):
# 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', '')})
# Add spell information # Add spell information
if 'spells' in properties: if 'spells' in properties:
spell_info = properties['spells'] spell_info = properties['spells']
@ -2168,11 +2252,15 @@ async def search_items(
if slot_name and slot_name not in slot_names: if slot_name and slot_name not in slot_names:
slot_names.append(slot_name) slot_names.append(slot_name)
item['slot_name'] = ', '.join(slot_names) if slot_names else f"Slot_{equippable_slots}" # 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 "-"
else: else:
item['slot_name'] = "Unknown" item['slot_name'] = "-"
else: else:
item['slot_name'] = "Unknown" item['slot_name'] = "-"
# Use gear totals as display ratings when individual ratings don't exist # 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) # For armor/clothing, ratings are often stored as gear totals (370, 372, 374)
@ -3487,33 +3575,6 @@ def decode_coverage_to_slots(coverage_mask, object_class=None, item_name=''):
return list(set(slots)) # Remove duplicates return list(set(slots)) # Remove duplicates
def translate_equipment_slot(slot_option):
"""
Translate equipment slot option to human-readable slot name.
This handles the slot options returned by get_sophisticated_slot_options.
"""
# Equipment slot mappings based on EquipMask values
slot_mappings = {
1: "Head",
2: "Chest",
4: "Abdomen",
8: "Upper Arms",
16: "Lower Arms",
32: "Hands",
64: "Upper Legs",
128: "Lower Legs",
256: "Feet",
512: "Shield",
1024: "Neck",
2048: "Left Wrist",
4096: "Right Wrist",
8192: "Left Ring",
16384: "Right Ring",
32768: "Trinket"
}
return slot_mappings.get(slot_option, f"Slot_{slot_option}")
def categorize_items_by_set(items): def categorize_items_by_set(items):
"""Categorize items by equipment set for efficient set-based optimization.""" """Categorize items by equipment set for efficient set-based optimization."""

View file

@ -7,3 +7,4 @@ pydantic==2.5.0
python-multipart==0.0.6 python-multipart==0.0.6
python-json-logger==2.0.7 python-json-logger==2.0.7
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
sse-starlette==1.8.2

439
main.py
View file

@ -10,6 +10,7 @@ import json
import logging import logging
import os import os
import sys import sys
import time
from typing import Dict, List, Any from typing import Dict, List, Any
from pathlib import Path from pathlib import Path
@ -60,6 +61,249 @@ _cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
_cache_task: asyncio.Task | None = None _cache_task: asyncio.Task | None = None
_rares_cache_task: asyncio.Task | None = None _rares_cache_task: asyncio.Task | None = None
# 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
# 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
# Simple WebSocket connection counters (Phase 1)
_plugin_connections = 0
_browser_connections = 0
# Simple database query performance counters (Phase 2)
_total_queries = 0
_total_query_time = 0.0
# Simple recent activity tracking (Phase 3)
_recent_telemetry_messages = []
_max_recent_messages = 50
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'))
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:
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
}
_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}")
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",
"character_name": player,
"total_players": len(current_players),
"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}")
# Add current state to history
history_entry = {
"timestamp": timestamp,
"players": new_players,
"player_count": len(new_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
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"}
# 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():
if len(activity) >= 4: # At least 4 events
# Count alternating enter/exit patterns
flap_score = 0
for i in range(1, len(activity)):
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
})
# 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}
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
},
"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()
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),
"avg_interval": round(avg_interval, 1),
"min_interval": round(min_interval, 1),
"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,
"has_timing_issues": has_timing_issues,
"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()],
}
}
async def _refresh_cache_loop() -> None: async def _refresh_cache_loop() -> None:
"""Background task: refresh `/live` and `/trails` caches every 5 seconds.""" """Background task: refresh `/live` and `/trails` caches every 5 seconds."""
consecutive_failures = 0 consecutive_failures = 0
@ -92,7 +336,12 @@ async def _refresh_cache_loop() -> None:
# Use a single connection for both queries to reduce connection churn # Use a single connection for both queries to reduce connection churn
async with database.connection() as conn: async with database.connection() as conn:
rows = await conn.fetch_all(sql_live, {"cutoff": cutoff}) rows = await conn.fetch_all(sql_live, {"cutoff": cutoff})
_cached_live["players"] = [dict(r) for r in rows] 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) # Recompute trails (last 600s)
cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600) cutoff2 = datetime.utcnow().replace(tzinfo=timezone.utc) - timedelta(seconds=600)
@ -347,6 +596,104 @@ async def on_shutdown():
def debug(): def debug():
return {"status": "OK"} 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
# 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"]
}
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)
return {
"current_players": len(_cached_live.get("players", [])),
"history": formatted_history,
"recent_events": formatted_events,
"flapping_analysis": flapping_analysis,
"timing_analysis": {
"all_players": formatted_timing,
"problem_players": timing_analysis["problem_players"],
"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
}
}
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."""
try:
return {
"plugin_connections": _plugin_connections,
"browser_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
return {
"total_queries": _total_queries,
"total_query_time": round(_total_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."""
try:
return {
"recent_messages": _recent_telemetry_messages.copy(),
"total_messages": len(_recent_telemetry_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("/live", response_model=dict) @app.get("/live", response_model=dict)
@app.get("/live/", response_model=dict) @app.get("/live/", response_model=dict)
@ -899,6 +1246,8 @@ async def ws_receive_snapshots(
- rare: update total and session rare counts, persist event - rare: update total and session rare counts, persist event
- chat: broadcast chat messages to browsers - chat: broadcast chat messages to browsers
""" """
global _plugin_connections
# Authenticate plugin connection using shared secret # Authenticate plugin connection using shared secret
key = secret or x_plugin_secret key = secret or x_plugin_secret
if key != SHARED_SECRET: if key != SHARED_SECRET:
@ -908,7 +1257,11 @@ async def ws_receive_snapshots(
return return
# Accept the WebSocket connection # Accept the WebSocket connection
await websocket.accept() await websocket.accept()
logger.info(f"Plugin WebSocket connected: {websocket.client}") logger.info(f"🔌 PLUGIN_CONNECTED: {websocket.client}")
# Track plugin connection
_plugin_connections += 1
try: try:
while True: while True:
# Read next text frame # Read next text frame
@ -917,7 +1270,7 @@ async def ws_receive_snapshots(
# Debug: log all incoming plugin WebSocket messages # Debug: log all incoming plugin WebSocket messages
logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}") logger.debug(f"Plugin WebSocket RX from {websocket.client}: {raw}")
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"Plugin WebSocket disconnected: {websocket.client}") logger.info(f"🔌 PLUGIN_DISCONNECTED: {websocket.client}")
break break
# Parse JSON payload # Parse JSON payload
try: try:
@ -931,7 +1284,7 @@ async def ws_receive_snapshots(
name = data.get("character_name") or data.get("player_name") name = data.get("character_name") or data.get("player_name")
if isinstance(name, str): if isinstance(name, str):
plugin_conns[name] = websocket plugin_conns[name] = websocket
logger.info(f"Registered plugin connection for character: {name}") logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}")
continue continue
# --- Spawn event: persist to spawn_events table --- # --- Spawn event: persist to spawn_events table ---
if msg_type == "spawn": if msg_type == "spawn":
@ -952,6 +1305,12 @@ async def ws_receive_snapshots(
# Parse telemetry snapshot and update in-memory state # Parse telemetry snapshot and update in-memory state
payload = data.copy() payload = data.copy()
payload.pop("type", None) payload.pop("type", None)
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}")
try: try:
snap = TelemetrySnapshot.parse_obj(payload) snap = TelemetrySnapshot.parse_obj(payload)
live_snapshots[snap.character_name] = snap.dict() live_snapshots[snap.character_name] = snap.dict()
@ -974,6 +1333,16 @@ async def ws_receive_snapshots(
delta = snap.kills - last delta = snap.kills - last
# Persist snapshot and any kill delta in a single transaction # 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"
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}")
try: try:
async with database.transaction(): async with database.transaction():
await database.execute( await database.execute(
@ -989,15 +1358,60 @@ async def ws_receive_snapshots(
) )
await database.execute(stmt) 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 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
# Track recent activity (Phase 3)
global _recent_telemetry_messages, _max_recent_messages
activity_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"character_name": snap.character_name,
"kills": snap.kills,
"kill_delta": delta,
"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"
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}")
except Exception as db_error: except Exception as db_error:
logger.error(f"💾 Database transaction failed for {snap.character_name} (session: {snap.session_id[:8]}): {db_error}", exc_info=True) 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"
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)
continue continue
# Broadcast updated snapshot to all browser clients # Broadcast updated snapshot to all browser clients
await _broadcast_to_browser_clients(snap.dict()) await _broadcast_to_browser_clients(snap.dict())
logger.debug(f"✅ Processed telemetry from {snap.character_name} (session: {snap.session_id[:8]}, kills: {snap.kills})")
# 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")
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to process telemetry event from {data.get('character_name', 'unknown')}: {e}", exc_info=True) 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)
continue continue
# --- Rare event: update total and session counters and persist --- # --- Rare event: update total and session counters and persist ---
if msg_type == "rare": if msg_type == "rare":
@ -1082,6 +1496,9 @@ async def ws_receive_snapshots(
if msg_type: 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: finally:
# Track plugin disconnection
_plugin_connections = max(0, _plugin_connections - 1)
# Clean up any plugin registrations for this socket # Clean up any plugin registrations for this socket
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket] to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
for n in to_remove: for n in to_remove:
@ -1153,10 +1570,15 @@ async def ws_live_updates(websocket: WebSocket):
Manages a set of connected browser clients; listens for incoming command messages Manages a set of connected browser clients; listens for incoming command messages
and forwards them to the appropriate plugin client WebSocket. and forwards them to the appropriate plugin client WebSocket.
""" """
global _browser_connections
# Add new browser client to the set # Add new browser client to the set
await websocket.accept() await websocket.accept()
browser_conns.add(websocket) browser_conns.add(websocket)
logger.info(f"Browser WebSocket connected: {websocket.client}") logger.info(f"Browser WebSocket connected: {websocket.client}")
# Track browser connection
_browser_connections += 1
try: try:
while True: while True:
# Receive command messages from browser # Receive command messages from browser
@ -1198,6 +1620,9 @@ async def ws_live_updates(websocket: WebSocket):
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
# Track browser disconnection
_browser_connections = max(0, _browser_connections - 1)
browser_conns.discard(websocket) 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}")

654
static/debug.html Normal file
View file

@ -0,0 +1,654 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Player Debug - Dereth Tracker</title>
<style>
body {
font-family: "Segoe UI", sans-serif;
background: #111;
color: #eee;
margin: 20px;
line-height: 1.4;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #222;
border: 1px solid #444;
border-radius: 8px;
padding: 15px;
}
.stat-card h3 {
margin: 0 0 10px 0;
color: #88f;
font-size: 1.1rem;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
color: #fff;
}
.sections {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.section {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
}
.section h2 {
margin: 0 0 15px 0;
color: #88f;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.event-log {
max-height: 400px;
overflow-y: auto;
background: #000;
border: 1px solid #333;
border-radius: 4px;
padding: 10px;
font-family: monospace;
font-size: 0.9rem;
}
.event-enter {
color: #4f4;
}
.event-exit {
color: #f44;
}
.flapping-player {
background: #332;
border: 1px solid #664;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.flapping-player .name {
font-weight: bold;
color: #ff6;
}
.flapping-player .score {
color: #f88;
font-size: 0.9rem;
}
.history-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #333;
font-family: monospace;
font-size: 0.9rem;
}
.history-entry:last-child {
border-bottom: none;
}
.timestamp {
color: #888;
font-size: 0.8rem;
}
.player-count {
font-weight: bold;
color: #4f4;
}
.status {
text-align: center;
padding: 20px;
background: #222;
border-radius: 8px;
margin-bottom: 20px;
}
.loading {
color: #88f;
}
.error {
color: #f44;
}
.last-updated {
text-align: center;
color: #888;
font-size: 0.9rem;
margin-top: 20px;
}
.timing-player {
background: #223;
border: 1px solid #446;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.timing-player .name {
font-weight: bold;
color: #ff8;
}
.timing-player .issue {
color: #f88;
font-size: 0.9rem;
}
.timing-player .stats {
color: #aaa;
font-size: 0.8rem;
margin-top: 5px;
font-family: monospace;
}
.timing-good {
border-color: #464 !important;
background: #232 !important;
}
.timing-warning {
border-color: #664 !important;
background: #332 !important;
}
.timing-critical {
border-color: #644 !important;
background: #322 !important;
}
@media (max-width: 768px) {
.sections {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔍 Player Debug Dashboard</h1>
<p>Real-time tracking of player list changes to debug flapping issues</p>
</div>
<div class="status" id="status">
<div class="loading">Loading player debug data...</div>
</div>
<div class="stats-grid" id="statsGrid" style="display: none;">
<div class="stat-card">
<h3>Current Players</h3>
<div class="stat-value" id="currentPlayers">-</div>
</div>
<div class="stat-card">
<h3>Total Events</h3>
<div class="stat-value" id="totalEvents">-</div>
</div>
<div class="stat-card">
<h3>Flapping Players</h3>
<div class="stat-value" id="flappingCount">-</div>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<div class="stat-value" id="recentActivity">-</div>
</div>
<div class="stat-card">
<h3>Timing Issues</h3>
<div class="stat-value" id="timingIssues">-</div>
</div>
<div class="stat-card">
<h3>Tracked Players</h3>
<div class="stat-value" id="trackedPlayers">-</div>
</div>
<div class="stat-card">
<h3>WebSocket Connections</h3>
<div class="stat-value" id="websocketConnections">-</div>
</div>
<div class="stat-card">
<h3>Database Queries</h3>
<div class="stat-value" id="databaseQueries">-</div>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<div class="stat-value" id="recentActivityCount">-</div>
</div>
</div>
<div class="sections" id="sections" style="display: none;">
<div class="section">
<h2>🔄 Flapping Players</h2>
<div id="flappingPlayers">
<p style="color: #888; font-style: italic;">No flapping detected</p>
</div>
</div>
<div class="section">
<h2>⏰ Timing Issues</h2>
<div id="timingProblems">
<p style="color: #888; font-style: italic;">Loading timing analysis...</p>
</div>
</div>
<div class="section">
<h2>📊 Player History</h2>
<div id="playerHistory" class="event-log">
<p style="color: #888; font-style: italic;">Loading history...</p>
</div>
</div>
<div class="section">
<h2>📝 Recent Events</h2>
<div id="recentEvents" class="event-log">
<p style="color: #888; font-style: italic;">Loading events...</p>
</div>
</div>
<div class="section">
<h2>🎯 Most Active Players</h2>
<div id="frequentEvents">
<p style="color: #888; font-style: italic;">Loading activity...</p>
</div>
</div>
<div class="section">
<h2>📈 Telemetry Timing</h2>
<div id="telemetryTiming">
<p style="color: #888; font-style: italic;">Loading telemetry data...</p>
</div>
</div>
<div class="section">
<h2>🔌 WebSocket Health</h2>
<div id="websocketHealth">
<p style="color: #888; font-style: italic;">Loading WebSocket data...</p>
</div>
</div>
<div class="section">
<h2>🗄️ Database Performance</h2>
<div id="databasePerformance">
<p style="color: #888; font-style: italic;">Loading database data...</p>
</div>
</div>
<div class="section">
<h2>⚡ Recent Activity</h2>
<div id="recentActivityFeed" class="event-log">
<p style="color: #888; font-style: italic;">Loading recent activity...</p>
</div>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
<script>
let pollInterval;
let isPolling = false;
function formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleTimeString();
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleString();
}
async function fetchDebugData() {
try {
// Fetch all four endpoints
const [flappingResponse, websocketResponse, databaseResponse, activityResponse] = await Promise.all([
fetch('/debug/player-flapping'),
fetch('/debug/websocket-health'),
fetch('/debug/database-performance'),
fetch('/debug/recent-activity')
]);
if (!flappingResponse.ok) {
throw new Error(`HTTP ${flappingResponse.status}: ${flappingResponse.statusText}`);
}
const data = await flappingResponse.json();
const websocketData = websocketResponse.ok ? await websocketResponse.json() : null;
const databaseData = databaseResponse.ok ? await databaseResponse.json() : null;
const activityData = activityResponse.ok ? await activityResponse.json() : null;
// Combine the data
const enhancedData = {
...data,
websocket_health: websocketData,
database_performance: databaseData,
recent_activity: activityData
};
updateUI(enhancedData);
showSuccess();
} catch (error) {
console.error('Failed to fetch debug data:', error);
showError(`Failed to fetch data: ${error.message}`);
}
}
function updateUI(data) {
// Update stats
document.getElementById('currentPlayers').textContent = data.current_players;
document.getElementById('totalEvents').textContent = data.tracking_stats.total_events;
document.getElementById('flappingCount').textContent = data.flapping_analysis.flapping_players.length;
const recentActivity = data.flapping_analysis.recent_activity;
document.getElementById('recentActivity').textContent =
`+${recentActivity.enters} -${recentActivity.exits} (${recentActivity.net_change > 0 ? '+' : ''}${recentActivity.net_change})`;
// Update flapping players
const flappingDiv = document.getElementById('flappingPlayers');
if (data.flapping_analysis.flapping_players.length === 0) {
flappingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No flapping detected</p>';
} else {
flappingDiv.innerHTML = data.flapping_analysis.flapping_players.map(player => `
<div class="flapping-player">
<div class="name">${player.character_name}</div>
<div class="score">Flap Score: ${player.flap_score} | Events: ${player.events}</div>
<div style="font-size: 0.8rem; color: #aaa; margin-top: 5px;">
Recent: ${player.recent_activity.join(' → ')}
</div>
</div>
`).join('');
}
// Update history
const historyDiv = document.getElementById('playerHistory');
if (data.history.length === 0) {
historyDiv.innerHTML = '<p style="color: #888; font-style: italic;">No history available</p>';
} else {
historyDiv.innerHTML = data.history.slice().reverse().map(entry => `
<div class="history-entry">
<span class="timestamp">${formatTimestamp(entry.timestamp)}</span>
<span class="player-count">${entry.player_count} players</span>
</div>
`).join('');
}
// Update recent events
const eventsDiv = document.getElementById('recentEvents');
if (data.recent_events.length === 0) {
eventsDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent events</p>';
} else {
eventsDiv.innerHTML = data.recent_events.slice().reverse().map(event => `
<div class="event-${event.type}">
${formatTimestamp(event.timestamp)} - ${event.character_name} ${event.type === 'enter' ? 'joined' : 'left'} (${event.total_players} total)
</div>
`).join('');
}
// Update frequent events
const frequentDiv = document.getElementById('frequentEvents');
if (data.flapping_analysis.frequent_events.length === 0) {
frequentDiv.innerHTML = '<p style="color: #888; font-style: italic;">No activity data</p>';
} else {
frequentDiv.innerHTML = data.flapping_analysis.frequent_events.map(player => `
<div style="display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #333;">
<span>${player.character_name}</span>
<span style="color: #88f;">${player.event_count} events</span>
</div>
`).join('');
}
// Update timing issues
const timingDiv = document.getElementById('timingProblems');
const timingData = data.timing_analysis || { problem_players: [], summary: { total_tracked_players: 0 } };
document.getElementById('timingIssues').textContent = timingData.problem_players.length;
document.getElementById('trackedPlayers').textContent = timingData.summary.total_tracked_players;
if (timingData.problem_players.length === 0) {
timingDiv.innerHTML = '<p style="color: #4f4; font-style: italic;">✅ No timing issues detected</p>';
} else {
timingDiv.innerHTML = timingData.problem_players.map(player => {
let severityClass = 'timing-good';
const maxInterval = player.max_interval || 0;
const avgInterval = player.avg_interval || 0;
const totalMessages = player.total_messages || 0;
if (maxInterval > 60) severityClass = 'timing-critical';
else if (maxInterval > 45) severityClass = 'timing-warning';
const issue = maxInterval > 30 ?
`Max gap: ${maxInterval.toFixed(1)}s (>${30}s threshold)` :
`Irregular timing patterns detected`;
return `
<div class="timing-player ${severityClass}">
<div class="name">${player.character_name || 'Unknown'}</div>
<div class="issue">${issue}</div>
<div class="stats">
Messages: ${totalMessages} |
Avg gap: ${avgInterval.toFixed(1)}s |
Max gap: ${maxInterval.toFixed(1)}s
</div>
</div>
`;
}).join('');
}
// Update telemetry timing section
const telemetryDiv = document.getElementById('telemetryTiming');
if (timingData.summary.total_tracked_players === 0) {
telemetryDiv.innerHTML = '<p style="color: #888; font-style: italic;">No telemetry data available</p>';
} else {
const healthyCount = timingData.summary.total_tracked_players - timingData.problem_players.length;
telemetryDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Players Tracked:</span>
<span style="color: #88f; font-weight: bold;">${timingData.summary.total_tracked_players}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Healthy Timing:</span>
<span style="color: #4f4; font-weight: bold;">${healthyCount}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Timing Issues:</span>
<span style="color: #f44; font-weight: bold;">${timingData.problem_players.length}</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa; line-height: 1.4;">
Players with telemetry gaps >30s are flagged as timing issues.
This can cause players to temporarily disappear from the active list.
</div>
`;
}
// Update WebSocket health data
const websocketData = data.websocket_health;
if (websocketData) {
// Update stat card
document.getElementById('websocketConnections').textContent =
`${websocketData.total_connections} (P:${websocketData.plugin_connections}/B:${websocketData.browser_connections})`;
// Update detailed section
const websocketDiv = document.getElementById('websocketHealth');
websocketDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Plugin Connections:</span>
<span style="color: #88f; font-weight: bold;">${websocketData.plugin_connections}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Browser Connections:</span>
<span style="color: #4f4; font-weight: bold;">${websocketData.browser_connections}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Total Connections:</span>
<span style="color: #ff8; font-weight: bold;">${websocketData.total_connections}</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa;">
Plugin connections receive telemetry from game clients.<br>
Browser connections show live updates to web users.
</div>
`;
} else {
document.getElementById('websocketConnections').textContent = 'Error';
document.getElementById('websocketHealth').innerHTML = '<p style="color: #f44;">Failed to load WebSocket data</p>';
}
// Update database performance data
const databaseData = data.database_performance;
if (databaseData) {
// Update stat card
document.getElementById('databaseQueries').textContent =
`${databaseData.total_queries} (${databaseData.average_query_time}s avg)`;
// Update detailed section
const databaseDiv = document.getElementById('databasePerformance');
databaseDiv.innerHTML = `
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Queries:</span>
<span style="color: #88f; font-weight: bold;">${databaseData.total_queries}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Total Query Time:</span>
<span style="color: #4f4; font-weight: bold;">${databaseData.total_query_time}s</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Average Query Time:</span>
<span style="color: #ff8; font-weight: bold;">${databaseData.average_query_time}s</span>
</div>
</div>
<div style="font-size: 0.9rem; color: #aaa;">
Tracks telemetry database write operations performance.<br>
Each telemetry message triggers 1-2 database queries.
</div>
`;
} else {
document.getElementById('databaseQueries').textContent = 'Error';
document.getElementById('databasePerformance').innerHTML = '<p style="color: #f44;">Failed to load database performance data</p>';
}
// Update recent activity data
const activityData = data.recent_activity;
if (activityData) {
// Update stat card
document.getElementById('recentActivityCount').textContent =
`${activityData.total_messages}/${activityData.max_messages}`;
// Update detailed section
const activityDiv = document.getElementById('recentActivityFeed');
if (activityData.recent_messages.length === 0) {
activityDiv.innerHTML = '<p style="color: #888; font-style: italic;">No recent activity</p>';
} else {
activityDiv.innerHTML = activityData.recent_messages.slice().reverse().map(msg => {
const timeStr = formatTimestamp(msg.timestamp);
const killInfo = msg.kill_delta > 0 ? ` (+${msg.kill_delta} kills)` : '';
const queryTime = msg.query_time ? ` ${msg.query_time}ms` : '';
return `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid #333; font-family: monospace; font-size: 0.85rem;">
<span>
<span style="color: #888;">${timeStr}</span>
<span style="color: #88f; margin-left: 8px;">${msg.character_name}</span>
<span style="color: #aaa; margin-left: 8px;">kills:${msg.kills}${killInfo}</span>
</span>
<span style="color: #666;">${queryTime}</span>
</div>
`;
}).join('');
}
} else {
document.getElementById('recentActivityCount').textContent = 'Error';
document.getElementById('recentActivityFeed').innerHTML = '<p style="color: #f44;">Failed to load recent activity data</p>';
}
// Update last updated
document.getElementById('lastUpdated').textContent = `Last updated: ${formatDate(new Date().toISOString())}`;
}
function showSuccess() {
document.getElementById('status').style.display = 'none';
document.getElementById('statsGrid').style.display = 'grid';
document.getElementById('sections').style.display = 'grid';
}
function showError(message) {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="error">❌ ${message}</div>`;
statusDiv.style.display = 'block';
document.getElementById('statsGrid').style.display = 'none';
document.getElementById('sections').style.display = 'none';
}
function startPolling() {
if (isPolling) return;
isPolling = true;
fetchDebugData(); // Initial fetch
pollInterval = setInterval(fetchDebugData, 5000); // Poll every 5 seconds
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
isPolling = false;
}
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
});
// Start polling when page loads
window.addEventListener('load', startPolling);
// Stop polling when page unloads
window.addEventListener('beforeunload', stopPolling);
</script>
</body>
</html>

View file

@ -46,6 +46,20 @@
</a> </a>
</div> </div>
<!-- Suitbuilder link -->
<div class="suitbuilder-link">
<a href="#" id="suitbuilderBtn" onclick="openSuitbuilder()">
🛡️ Suitbuilder
</a>
</div>
<!-- Player Debug link -->
<div class="debug-link">
<a href="#" id="playerDebugBtn" onclick="openPlayerDebug()">
🔍 Player Debug
</a>
</div>
<!-- Text input to filter active players by name --> <!-- Text input to filter active players by name -->
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." /> <input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />

715
static/inventory.html Normal file
View file

@ -0,0 +1,715 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inventory Search - Dereth Tracker</title>
<link rel="stylesheet" href="style.css">
<style>
/* Override main app styles to enable scrolling */
html, body {
overflow: auto !important;
height: auto !important;
display: block !important;
margin: 0;
padding: 0;
font-size: 11px;
font-family: Arial, sans-serif;
background: #f0f0f0;
}
/* Inventory-specific styles */
.inventory-container {
display: flex;
gap: 5px;
padding: 5px;
}
.character-sidebar {
width: 120px;
background: white;
border: 1px solid #999;
padding: 3px;
height: fit-content;
}
.character-sidebar h4 {
margin: 0 0 3px 0;
font-size: 10px;
font-weight: bold;
color: #000;
}
.character-list {
max-height: 300px;
overflow-y: auto;
}
.character-item {
display: flex;
align-items: center;
margin-bottom: 1px;
font-size: 9px;
}
.character-item input[type="checkbox"] {
margin: 0 2px 0 0;
width: 9px;
height: 9px;
}
.character-item label {
cursor: pointer;
color: #000;
flex: 1;
}
.main-content {
flex: 1;
}
.inventory-header {
background: #333;
color: white;
padding: 4px 8px;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.inventory-header h1 {
margin: 0;
font-size: 14px;
font-weight: normal;
}
.search-form {
background: white;
padding: 5px;
border: 1px solid #ccc;
}
.filter-row {
display: flex;
gap: 10px;
margin-bottom: 5px;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 3px;
}
.filter-group label {
font-weight: bold;
font-size: 10px;
color: #000;
}
input[type="text"],
input[type="number"],
select {
border: 1px solid #999;
padding: 1px 3px;
font-size: 11px;
height: 18px;
}
input[type="text"] {
width: 120px;
}
input[type="number"] {
width: 40px;
}
select {
width: 100px;
}
.filter-section {
display: flex;
align-items: flex-start;
gap: 5px;
margin-bottom: 3px;
}
.section-label {
font-weight: bold;
font-size: 10px;
min-width: 50px;
padding-top: 2px;
color: #000;
}
.checkbox-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex: 1;
}
.checkbox-item {
display: flex;
align-items: center;
font-size: 9px;
white-space: nowrap;
}
.checkbox-item input[type="checkbox"] {
margin: 0 1px 0 0;
width: 9px;
height: 9px;
}
.checkbox-item label {
cursor: pointer;
color: #000;
}
.search-actions {
display: flex;
gap: 5px;
margin-top: 3px;
}
.btn {
padding: 2px 8px;
border: 1px solid #999;
background: #e0e0e0;
font-size: 10px;
cursor: pointer;
}
.btn-primary {
background: #4a80c0;
color: white;
border-color: #336699;
}
.btn:hover {
background: #d0d0d0;
}
.btn-primary:hover {
background: #336699;
}
.results-container {
background: white;
border: 1px solid #999;
margin-top: 5px;
max-height: calc(100vh - 150px);
overflow: auto;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
color: #000;
}
.results-table th {
background: #d0d0d0;
padding: 1px 3px;
text-align: left;
font-weight: bold;
border: 1px solid #999;
position: sticky;
top: 0;
font-size: 9px;
}
.results-table td {
padding: 1px 3px;
border: 1px solid #ddd;
}
.results-table tbody tr:hover {
background-color: #f0f0f0;
}
.item-name {
color: #0066cc;
font-weight: bold;
}
.status-equipped {
color: #008800;
}
.status-inventory {
color: #5f6368;
}
.loading {
text-align: center;
padding: 20px;
color: #5f6368;
font-size: 11px;
}
.no-results {
text-align: center;
padding: 20px;
color: #5f6368;
font-size: 11px;
}
.error {
text-align: center;
padding: 10px;
color: #d93025;
background: #fef7e0;
margin: 5px;
font-size: 11px;
}
.results-info {
padding: 3px 5px;
background: #f0f0f0;
border-bottom: 1px solid #999;
font-weight: normal;
color: #333;
font-size: 10px;
}
.back-link {
display: inline-flex;
align-items: center;
color: #1a73e8;
text-decoration: none;
font-weight: 500;
margin-bottom: 16px;
transition: color 0.2s;
}
.back-link:hover {
color: #1557b0;
}
.back-link::before {
content: "←";
margin-right: 8px;
font-size: 1.2rem;
}
/* Set Analysis specific styles */
.set-analysis-section {
background: white;
border: 1px solid #999;
margin-top: 5px;
}
.set-analysis-form {
background: #f8f8f8;
padding: 8px;
border-bottom: 1px solid #ddd;
}
.set-analysis-form h3 {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: bold;
color: #333;
}
.set-analysis-form select {
width: 140px;
}
.text-right {
text-align: right;
}
</style>
</head>
<body>
<div class="inventory-header">
<h1>Inventory Search</h1>
<a href="/" style="color: white; text-decoration: none; font-size: 11px;">← Back</a>
</div>
<div class="inventory-container">
<!-- Character Selection Sidebar -->
<div class="character-sidebar">
<h4>Characters</h4>
<div class="character-item">
<input type="checkbox" id="char_all" checked>
<label for="char_all">All Characters</label>
</div>
<div class="character-list" id="characterList">
<!-- Character checkboxes will be populated by JavaScript -->
</div>
</div>
<div class="main-content">
<form class="search-form" id="inventorySearchForm">
<!-- Row 0: Equipment Type Selection -->
<div class="filter-row">
<div class="filter-group">
<label>Type:</label>
<div style="display: flex; gap: 10px;">
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="armorOnly" value="armor" checked style="margin-right: 3px;">
Armor Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="jewelryOnly" value="jewelry" style="margin-right: 3px;">
Jewelry Only
</label>
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="radio" name="equipmentType" id="allItems" value="all" style="margin-right: 3px;">
All Items
</label>
</div>
</div>
</div>
<!-- Row 0.5: Slot Selection -->
<div class="filter-row">
<div class="filter-group">
<label>Slot:</label>
<select id="slotFilter">
<option value="">All Slots</option>
<optgroup label="Armor Slots">
<option value="Head">Head</option>
<option value="Chest">Chest</option>
<option value="Upper Arms">Upper Arms</option>
<option value="Lower Arms">Lower Arms</option>
<option value="Hands">Hands</option>
<option value="Abdomen">Abdomen</option>
<option value="Upper Legs">Upper Legs</option>
<option value="Lower Legs">Lower Legs</option>
<option value="Feet">Feet</option>
<option value="Shield">Shield</option>
</optgroup>
<optgroup label="Jewelry Slots">
<option value="Neck">Neck</option>
<option value="Left Ring">Left Ring</option>
<option value="Right Ring">Right Ring</option>
<option value="Left Wrist">Left Wrist</option>
<option value="Right Wrist">Right Wrist</option>
<option value="Trinket">Trinket</option>
</optgroup>
</select>
</div>
</div>
<!-- Row 1: Basic filters -->
<div class="filter-row">
<div class="filter-group">
<label>Name:</label>
<input type="text" id="searchText" placeholder="Item name">
</div>
<div class="filter-group">
<label>Status:</label>
<select id="searchEquipStatus">
<option value="all">All</option>
<option value="equipped">Equipped</option>
<option value="unequipped">Inventory</option>
</select>
</div>
</div>
<!-- Row 2: Stats -->
<div class="filter-row">
<div class="filter-group">
<label>Armor:</label>
<input type="number" id="searchMinArmor" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxArmor" placeholder="Max">
</div>
<div class="filter-group">
<label>Crit:</label>
<input type="number" id="searchMinCritDamage" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxCritDamage" placeholder="Max">
</div>
<div class="filter-group">
<label>Dmg:</label>
<input type="number" id="searchMinDamageRating" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxDamageRating" placeholder="Max">
</div>
<div class="filter-group">
<label>Heal:</label>
<input type="number" id="searchMinHealBoost" placeholder="Min">
<span>-</span>
<input type="number" id="searchMaxHealBoost" placeholder="Max">
</div>
</div>
<!-- Equipment Sets -->
<div class="filter-section">
<label class="section-label">Set:</label>
<div class="checkbox-container" id="equipmentSets">
<div class="checkbox-item">
<input type="checkbox" id="set_14" value="14">
<label for="set_14">Adept's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_16" value="16">
<label for="set_16">Defender's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_13" value="13">
<label for="set_13">Soldier's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_21" value="21">
<label for="set_21">Wise</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_40" value="40">
<label for="set_40">Heroic Protector</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_41" value="41">
<label for="set_41">Heroic Destroyer</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_46" value="46">
<label for="set_46">Relic Alduressa</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_47" value="47">
<label for="set_47">Ancient Relic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_48" value="48">
<label for="set_48">Noble Relic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_15" value="15">
<label for="set_15">Archer's</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_19" value="19">
<label for="set_19">Hearty</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_20" value="20">
<label for="set_20">Dexterous</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_22" value="22">
<label for="set_22">Swift</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_24" value="24">
<label for="set_24">Reinforced</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_26" value="26">
<label for="set_26">Flame Proof</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="set_29" value="29">
<label for="set_29">Lightning Proof</label>
</div>
</div>
</div>
<!-- Legendary Cantrips -->
<div class="filter-section">
<label class="section-label">Cantrips:</label>
<div class="checkbox-container" id="cantrips">
<!-- Legendary Attributes -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_strength" value="Legendary Strength">
<label for="cantrip_legendary_strength">Strength</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_endurance" value="Legendary Endurance">
<label for="cantrip_legendary_endurance">Endurance</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_quickness" value="Legendary Quickness">
<label for="cantrip_legendary_quickness">Quickness</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_coordination" value="Legendary Coordination">
<label for="cantrip_legendary_coordination">Coordination</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_willpower" value="Legendary Willpower">
<label for="cantrip_legendary_willpower">Willpower</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_focus" value="Legendary Focus">
<label for="cantrip_legendary_focus">Focus</label>
</div>
<!-- Legendary Weapon Skills -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_finesse" value="Legendary Finesse Weapons">
<label for="cantrip_legendary_finesse">Finesse</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_heavy" value="Legendary Heavy Weapons">
<label for="cantrip_legendary_heavy">Heavy</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_light" value="Legendary Light Weapons">
<label for="cantrip_legendary_light">Light</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_missile" value="Legendary Missile Weapons">
<label for="cantrip_legendary_missile">Missile</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_twohanded" value="Legendary Two Handed Combat">
<label for="cantrip_legendary_twohanded">Two-handed</label>
</div>
<!-- Legendary Magic Skills -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_war" value="Legendary War Magic">
<label for="cantrip_legendary_war">War Magic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_void" value="Legendary Void Magic">
<label for="cantrip_legendary_void">Void Magic</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_creature" value="Legendary Creature Enchantment">
<label for="cantrip_legendary_creature">Creature</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_item" value="Legendary Item Enchantment">
<label for="cantrip_legendary_item">Item</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_life" value="Legendary Life Magic">
<label for="cantrip_legendary_life">Life Magic</label>
</div>
<!-- Legendary Defense -->
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_magic_defense" value="Legendary Magic Defense">
<label for="cantrip_legendary_magic_defense">Magic Def</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="cantrip_legendary_melee_defense" value="Legendary Melee Defense">
<label for="cantrip_legendary_melee_defense">Melee Def</label>
</div>
</div>
</div>
<!-- Legendary Wards -->
<div class="filter-section">
<label class="section-label">Wards:</label>
<div class="checkbox-container" id="protections">
<div class="checkbox-item">
<input type="checkbox" id="protection_flame" value="Legendary Flame Ward">
<label for="protection_flame">Flame</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_frost" value="Legendary Frost Ward">
<label for="protection_frost">Frost</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_acid" value="Legendary Acid Ward">
<label for="protection_acid">Acid</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_storm" value="Legendary Storm Ward">
<label for="protection_storm">Storm</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_slashing" value="Legendary Slashing Ward">
<label for="protection_slashing">Slashing</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_piercing" value="Legendary Piercing Ward">
<label for="protection_piercing">Piercing</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_bludgeoning" value="Legendary Bludgeoning Ward">
<label for="protection_bludgeoning">Bludgeoning</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="protection_armor" value="Legendary Armor">
<label for="protection_armor">Armor</label>
</div>
</div>
</div>
<div class="search-actions">
<button type="button" class="btn btn-secondary" id="clearBtn">Clear All</button>
<button type="submit" class="btn btn-primary">Search Items</button>
<button type="button" class="btn btn-secondary" id="setAnalysisBtn">Analyze Sets</button>
<button type="button" class="btn btn-secondary" id="slotViewBtn">Slot View</button>
</div>
</form>
<!-- Set Analysis Section -->
<div class="set-analysis-section" id="setAnalysisSection" style="display: none;">
<div class="set-analysis-form">
<h3>Equipment Set Combination Analysis</h3>
<div class="filter-row">
<div class="filter-group">
<label>Primary Set (5 pieces):</label>
<select id="primarySetSelect">
<option value="14">Adept's Set</option>
<option value="16">Defender's Set</option>
<option value="13">Soldier's Set</option>
<option value="21">Wise Set</option>
<option value="40">Heroic Protector</option>
<option value="41">Heroic Destroyer</option>
<option value="46">Relic Alduressa</option>
<option value="47">Ancient Relic</option>
<option value="48">Noble Relic</option>
</select>
</div>
<div class="filter-group">
<label>Secondary Set (4 pieces):</label>
<select id="secondarySetSelect">
<option value="13">Soldier's Set</option>
<option value="14">Adept's Set</option>
<option value="16">Defender's Set</option>
<option value="21">Wise Set</option>
<option value="40">Heroic Protector</option>
<option value="41">Heroic Destroyer</option>
<option value="46">Relic Alduressa</option>
<option value="47">Ancient Relic</option>
<option value="48">Noble Relic</option>
</select>
</div>
<div class="filter-group">
<button type="button" class="btn btn-primary" id="runSetAnalysis">Analyze</button>
<button type="button" class="btn btn-secondary" id="backToSearch">Back to Search</button>
</div>
</div>
</div>
<div class="results-container" id="setAnalysisResults">
<div class="no-results">Select primary and secondary sets above and click "Analyze" to find valid combinations.</div>
</div>
</div>
<!-- Slot View Section -->
<div class="slot-view-section" id="slotViewSection" style="display: none;">
<div class="slot-view-header">
<h3>Equipment Slot View</h3>
<div class="filter-row">
<button type="button" class="btn btn-primary" id="loadSlotView">Load Items</button>
<button type="button" class="btn btn-secondary" id="backToSearchFromSlots">Back to Search</button>
</div>
</div>
<div class="slots-grid" id="slotsGrid">
<!-- Slots will be populated by JavaScript -->
</div>
</div>
<div class="results-container" id="searchResults">
<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>
</div>
</div>
</div>
<script src="inventory.js"></script>
</body>
</html>

722
static/inventory.js Normal file
View file

@ -0,0 +1,722 @@
/**
* Inventory Search Application
* Dedicated JavaScript for the inventory search page
*/
// Configuration - use main app proxy for inventory service
const API_BASE = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8766' // Local development - direct to inventory service
: `${window.location.origin}/inv`; // Production - through main app proxy
// DOM Elements - will be set after DOM loads
let searchForm, clearBtn, searchResults;
// Sorting state
let currentSort = {
field: 'name',
direction: 'asc'
};
// Store current search results for client-side sorting
let currentResultsData = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
// Get DOM elements after DOM is loaded
searchForm = document.getElementById('inventorySearchForm');
clearBtn = document.getElementById('clearBtn');
searchResults = document.getElementById('searchResults');
initializeEventListeners();
loadCharacterOptions();
});
/**
* Initialize all event listeners
*/
function initializeEventListeners() {
// Form submission
searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
await performSearch();
});
// Clear button
clearBtn.addEventListener('click', clearAllFields);
// Slot filter change
document.getElementById('slotFilter').addEventListener('change', handleSlotFilterChange);
// Set analysis buttons
document.getElementById('setAnalysisBtn').addEventListener('click', showSetAnalysis);
document.getElementById('backToSearch').addEventListener('click', showSearchSection);
document.getElementById('runSetAnalysis').addEventListener('click', performSetAnalysis);
// Checkbox visual feedback for cantrips and equipment sets
document.querySelectorAll('.checkbox-item input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', handleCheckboxChange);
});
}
/**
* Load available characters for the checkbox list
*/
async function loadCharacterOptions() {
try {
// Use inventory service proxy endpoint for character list
const response = await fetch(`${window.location.origin}/inventory-characters`);
const data = await response.json();
if (data.characters && data.characters.length > 0) {
const characterList = document.getElementById('characterList');
// Sort characters by name
data.characters.sort((a, b) => a.character_name.localeCompare(b.character_name));
// Add character checkboxes
data.characters.forEach(char => {
const div = document.createElement('div');
div.className = 'character-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `char_${char.character_name}`;
checkbox.value = char.character_name;
checkbox.className = 'character-checkbox';
checkbox.checked = true; // Check all by default
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = char.character_name;
div.appendChild(checkbox);
div.appendChild(label);
characterList.appendChild(div);
});
// Set up event listeners for character selection
setupCharacterCheckboxes();
}
} catch (error) {
console.warn('Could not load character list:', error);
}
}
/**
* Setup character checkbox functionality
*/
function setupCharacterCheckboxes() {
const allCheckbox = document.getElementById('char_all');
const characterCheckboxes = document.querySelectorAll('.character-checkbox');
// Handle "All Characters" checkbox
allCheckbox.addEventListener('change', function() {
characterCheckboxes.forEach(cb => {
cb.checked = this.checked;
});
});
// Handle individual character checkboxes
characterCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
// If any individual checkbox is unchecked, uncheck "All"
if (!this.checked) {
allCheckbox.checked = false;
} else {
// If all individual checkboxes are checked, check "All"
const allChecked = Array.from(characterCheckboxes).every(checkbox => checkbox.checked);
allCheckbox.checked = allChecked;
}
});
});
}
/**
* Handle checkbox change events for visual feedback
*/
function handleCheckboxChange(e) {
const item = e.target.closest('.checkbox-item');
if (e.target.checked) {
item.classList.add('checked');
} else {
item.classList.remove('checked');
}
}
/**
* Clear all form fields and checkboxes
*/
function clearAllFields() {
searchForm.reset();
// Reset character selection to "All"
document.getElementById('char_all').checked = true;
document.querySelectorAll('.character-checkbox').forEach(cb => {
cb.checked = true;
});
// Clear checkbox visual states
document.querySelectorAll('.checkbox-item').forEach(item => {
item.classList.remove('checked');
});
// Reset equipment type to armor
document.getElementById('armorOnly').checked = true;
// Reset slot filter
document.getElementById('slotFilter').value = '';
// Reset results and clear stored data
currentResultsData = null;
searchResults.innerHTML = '<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>';
}
/**
* Handle slot filter changes
*/
function handleSlotFilterChange() {
// If we have current results, reapply filtering and sorting
if (currentResultsData) {
// Reset items to original unfiltered data
const originalData = JSON.parse(JSON.stringify(currentResultsData));
// Apply slot filtering
applySlotFilter(originalData);
// Apply sorting
sortResults(originalData);
// Display results
displayResults(originalData);
}
}
/**
* Perform the search based on form inputs
*/
async function performSearch() {
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
try {
const params = buildSearchParameters();
const searchUrl = `${API_BASE}/search/items?${params.toString()}`;
console.log('Search URL:', searchUrl);
const response = await fetch(searchUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Search failed');
}
// Store results for client-side re-sorting
currentResultsData = data;
// Apply client-side slot filtering
applySlotFilter(data);
// Sort the results client-side before displaying
sortResults(data);
displayResults(data);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = `<div class="error">❌ Search failed: ${error.message}</div>`;
}
}
/**
* Build search parameters from form inputs
*/
function buildSearchParameters() {
const params = new URLSearchParams();
// Equipment type selection
const equipmentType = document.querySelector('input[name="equipmentType"]:checked').value;
if (equipmentType === 'armor') {
params.append('armor_only', 'true');
} else if (equipmentType === 'jewelry') {
params.append('jewelry_only', 'true');
}
// If 'all' is selected, don't add any type filter
// Basic search parameters - handle character selection
const allCharactersChecked = document.getElementById('char_all').checked;
if (!allCharactersChecked) {
// Get selected characters
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length === 1) {
// Single character selected
params.append('character', selectedCharacters[0]);
} else if (selectedCharacters.length > 1) {
// Multiple characters - use comma-separated list
params.append('characters', selectedCharacters.join(','));
} else {
// No characters selected - search nothing
return { items: [], total_count: 0, page: 1, total_pages: 0 };
}
} else {
// All characters selected
params.append('include_all_characters', 'true');
}
addParam(params, 'text', 'searchText');
addParam(params, 'material', 'searchMaterial');
const equipStatus = document.getElementById('searchEquipStatus').value;
if (equipStatus && equipStatus !== 'all') {
params.append('equipment_status', equipStatus);
}
// Armor statistics parameters
addParam(params, 'min_armor', 'searchMinArmor');
addParam(params, 'max_armor', 'searchMaxArmor');
addParam(params, 'min_crit_damage_rating', 'searchMinCritDamage');
addParam(params, 'max_crit_damage_rating', 'searchMaxCritDamage');
addParam(params, 'min_damage_rating', 'searchMinDamageRating');
addParam(params, 'max_damage_rating', 'searchMaxDamageRating');
addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost');
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
// Requirements parameters
addParam(params, 'min_level', 'searchMinLevel');
addParam(params, 'max_level', 'searchMaxLevel');
addParam(params, 'min_workmanship', 'searchMinWorkmanship');
addParam(params, 'max_workmanship', 'searchMaxWorkmanship');
// Value parameters
addParam(params, 'min_value', 'searchMinValue');
addParam(params, 'max_value', 'searchMaxValue');
addParam(params, 'max_burden', 'searchMaxBurden');
// Equipment set filters
const selectedEquipmentSets = getSelectedEquipmentSets();
if (selectedEquipmentSets.length === 1) {
params.append('item_set', selectedEquipmentSets[0]);
} else if (selectedEquipmentSets.length > 1) {
params.append('item_sets', selectedEquipmentSets.join(','));
}
// Cantrip filters
const selectedCantrips = getSelectedCantrips();
const selectedProtections = getSelectedProtections();
const allSpells = [...selectedCantrips, ...selectedProtections];
if (allSpells.length > 0) {
params.append('legendary_cantrips', allSpells.join(','));
}
// Pagination only - sorting will be done client-side
params.append('limit', '1000'); // Show all items on one page
return params;
}
/**
* Helper function to add parameter if value exists
*/
function addParam(params, paramName, elementId) {
const value = document.getElementById(elementId)?.value?.trim();
if (value) {
params.append(paramName, value);
}
}
/**
* Get selected equipment sets
*/
function getSelectedEquipmentSets() {
const selectedSets = [];
document.querySelectorAll('#equipmentSets input[type="checkbox"]:checked').forEach(cb => {
selectedSets.push(cb.value);
});
return selectedSets;
}
/**
* Get selected legendary cantrips
*/
function getSelectedCantrips() {
const selectedCantrips = [];
document.querySelectorAll('#cantrips input[type="checkbox"]:checked').forEach(cb => {
selectedCantrips.push(cb.value);
});
return selectedCantrips;
}
/**
* Get selected protection spells
*/
function getSelectedProtections() {
const selectedProtections = [];
document.querySelectorAll('#protections input[type="checkbox"]:checked').forEach(cb => {
selectedProtections.push(cb.value);
});
return selectedProtections;
}
/**
* Display search results in the UI
*/
function displayResults(data) {
if (!data.items || data.items.length === 0) {
searchResults.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
return;
}
const getSortIcon = (field) => {
if (currentSort.field === field) {
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
}
return '';
};
let html = `
<div class="results-info">
Found <strong>${data.total_count}</strong> items - Showing all results
</div>
<table class="results-table">
<thead>
<tr>
<th class="sortable" data-sort="character_name">Character${getSortIcon('character_name')}</th>
<th class="sortable" data-sort="name">Item Name${getSortIcon('name')}</th>
<th class="sortable" data-sort="item_type_name">Type${getSortIcon('item_type_name')}</th>
<th class="text-right sortable" data-sort="slot_name">Slot${getSortIcon('slot_name')}</th>
<th class="text-right sortable" data-sort="coverage">Coverage${getSortIcon('coverage')}</th>
<th class="text-right sortable" data-sort="armor_level">Armor${getSortIcon('armor_level')}</th>
<th class="sortable" data-sort="spell_names">Spells/Cantrips${getSortIcon('spell_names')}</th>
<th class="text-right sortable" data-sort="crit_damage_rating">Crit Dmg${getSortIcon('crit_damage_rating')}</th>
<th class="text-right sortable" data-sort="damage_rating">Dmg Rating${getSortIcon('damage_rating')}</th>
<th class="text-right sortable" data-sort="last_updated">Last Updated${getSortIcon('last_updated')}</th>
</tr>
</thead>
<tbody>
`;
data.items.forEach((item) => {
const armor = item.armor_level > 0 ? item.armor_level : '-';
const critDmg = item.crit_damage_rating > 0 ? item.crit_damage_rating : '-';
const dmgRating = item.damage_rating > 0 ? item.damage_rating : '-';
const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory';
const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory';
// Use the slot_name provided by the API instead of incorrect mapping
const slot = item.slot_name || 'Unknown';
// Coverage placeholder - will need to be added to backend later
const coverage = item.coverage || '-';
// Format last updated timestamp
const lastUpdated = item.last_updated ?
new Date(item.last_updated).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) : '-';
// Use the formatted name with material from the API
let displayName = item.name;
// The API should already include material in the name, but use material_name if available
if (item.material_name && item.material_name !== '' && !item.name.toLowerCase().includes(item.material_name.toLowerCase())) {
displayName = `${item.material_name} ${item.name}`;
}
// Format spells/cantrips list
let spellsDisplay = '-';
if (item.spell_names && item.spell_names.length > 0) {
// Highlight legendary cantrips in a different color
const formattedSpells = item.spell_names.map(spell => {
if (spell.toLowerCase().includes('legendary')) {
return `<span class="legendary-cantrip">${spell}</span>`;
} else {
return `<span class="regular-spell">${spell}</span>`;
}
});
spellsDisplay = formattedSpells.join('<br>');
}
// Get item type for display
const itemType = item.item_type_name || '-';
html += `
<tr>
<td>${item.character_name}</td>
<td class="item-name">${displayName}</td>
<td>${itemType}</td>
<td class="text-right">${slot}</td>
<td class="text-right">${coverage}</td>
<td class="text-right">${armor}</td>
<td class="spells-cell">${spellsDisplay}</td>
<td class="text-right">${critDmg}</td>
<td class="text-right">${dmgRating}</td>
<td class="text-right">${lastUpdated}</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
// Add pagination info if needed
if (data.total_pages > 1) {
html += `
<div style="padding: 16px 24px; text-align: center; color: #5f6368; border-top: 1px solid #e8eaed;">
Showing page ${data.page} of ${data.total_pages} pages
</div>
`;
}
searchResults.innerHTML = html;
// Add click event listeners to sortable headers
document.querySelectorAll('.sortable').forEach(header => {
header.addEventListener('click', () => {
const sortField = header.getAttribute('data-sort');
handleSort(sortField);
});
});
}
/**
* Apply client-side slot filtering
*/
function applySlotFilter(data) {
const selectedSlot = document.getElementById('slotFilter').value;
if (!selectedSlot || !data.items) {
return; // No filter or no data
}
// Filter items that can be equipped in the selected slot
data.items = data.items.filter(item => {
const slotName = item.slot_name || '';
// Check if the item's slot_name contains the selected slot
// This handles multi-slot items like "Left Ring, Right Ring"
return slotName.includes(selectedSlot);
});
// Update total count
data.total_count = data.items.length;
}
/**
* Sort results client-side based on current sort settings
*/
function sortResults(data) {
if (!data.items || data.items.length === 0) return;
const field = currentSort.field;
const direction = currentSort.direction;
data.items.sort((a, b) => {
let aVal = a[field];
let bVal = b[field];
// Handle null/undefined values
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Special handling for spell_names array
if (field === 'spell_names') {
// Convert arrays to strings for sorting
aVal = Array.isArray(aVal) ? aVal.join(', ').toLowerCase() : '';
bVal = Array.isArray(bVal) ? bVal.join(', ').toLowerCase() : '';
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
// Determine if we're sorting numbers or strings
const isNumeric = typeof aVal === 'number' || (!isNaN(aVal) && !isNaN(parseFloat(aVal)));
if (isNumeric) {
// Numeric sorting
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
const result = aVal - bVal;
return direction === 'asc' ? result : -result;
} else {
// String sorting
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
const result = aVal.localeCompare(bVal);
return direction === 'asc' ? result : -result;
}
});
}
/**
* Handle column sorting
*/
function handleSort(field) {
// If clicking the same field, toggle direction
if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
// New field, default to ascending
currentSort.field = field;
currentSort.direction = 'asc';
}
// Re-display current results with new sorting (no new search needed)
if (currentResultsData) {
// Reset items to original unfiltered data
const originalData = JSON.parse(JSON.stringify(currentResultsData));
// Apply slot filtering first
applySlotFilter(originalData);
// Then apply sorting
sortResults(originalData);
// Display results
displayResults(originalData);
}
}
/**
* Show set analysis section
*/
function showSetAnalysis() {
document.getElementById('setAnalysisSection').style.display = 'block';
document.getElementById('searchResults').style.display = 'none';
searchForm.style.display = 'none';
}
/**
* Show search section
*/
function showSearchSection() {
document.getElementById('setAnalysisSection').style.display = 'none';
document.getElementById('searchResults').style.display = 'block';
searchForm.style.display = 'block';
}
/**
* Perform set combination analysis
*/
async function performSetAnalysis() {
const primarySet = document.getElementById('primarySetSelect').value;
const secondarySet = document.getElementById('secondarySetSelect').value;
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (primarySet === secondarySet) {
setAnalysisResults.innerHTML = '<div class="error">❌ Primary and secondary sets must be different.</div>';
return;
}
setAnalysisResults.innerHTML = '<div class="loading">🔍 Analyzing set combinations...</div>';
try {
const params = new URLSearchParams();
params.append('primary_set', primarySet);
params.append('secondary_set', secondarySet);
params.append('primary_count', '5');
params.append('secondary_count', '4');
// Use selected characters or all characters
const allCharactersChecked = document.getElementById('char_all').checked;
if (allCharactersChecked) {
params.append('include_all_characters', 'true');
} else {
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
.map(cb => cb.value);
if (selectedCharacters.length > 0) {
params.append('characters', selectedCharacters.join(','));
} else {
setAnalysisResults.innerHTML = '<div class="error">❌ Please select at least one character or check "All Characters".</div>';
return;
}
}
const analysisUrl = `${API_BASE}/analyze/sets?${params.toString()}`;
console.log('Set Analysis URL:', analysisUrl);
const response = await fetch(analysisUrl);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Set analysis failed');
}
displaySetAnalysisResults(data);
} catch (error) {
console.error('Set analysis error:', error);
setAnalysisResults.innerHTML = `<div class="error">❌ Set analysis failed: ${error.message}</div>`;
}
}
/**
* Display set analysis results
*/
function displaySetAnalysisResults(data) {
const setAnalysisResults = document.getElementById('setAnalysisResults');
if (!data.character_analysis || data.character_analysis.length === 0) {
setAnalysisResults.innerHTML = '<div class="no-results">No characters found with the selected sets.</div>';
return;
}
let html = `
<div class="results-info">
<strong>${data.primary_set.name}</strong> (${data.primary_set.pieces_needed} pieces) +
<strong>${data.secondary_set.name}</strong> (${data.secondary_set.pieces_needed} pieces)<br>
Found <strong>${data.characters_can_build}</strong> of <strong>${data.total_characters}</strong> characters who can build this combination
</div>
<table class="results-table">
<thead>
<tr>
<th>Character</th>
<th>Can Build?</th>
<th>${data.primary_set.name}</th>
<th>${data.secondary_set.name}</th>
<th>Primary Items</th>
<th>Secondary Items</th>
</tr>
</thead>
<tbody>
`;
data.character_analysis.forEach((char) => {
const canBuild = char.can_build_combination;
const canBuildText = canBuild ? '✅ Yes' : '❌ No';
const canBuildClass = canBuild ? 'status-equipped' : 'status-inventory';
const primaryStatus = `${char.primary_set_available}/${char.primary_set_needed}`;
const secondaryStatus = `${char.secondary_set_available}/${char.secondary_set_needed}`;
// Format item lists
const primaryItems = char.primary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
const secondaryItems = char.secondary_items.map(item =>
`${item.name}${item.equipped ? ' ⚔️' : ''}`
).join('<br>') || '-';
html += `
<tr>
<td><strong>${char.character_name}</strong></td>
<td class="${canBuildClass}">${canBuildText}</td>
<td class="text-right">${primaryStatus}</td>
<td class="text-right">${secondaryStatus}</td>
<td class="spells-cell">${primaryItems}</td>
<td class="spells-cell">${secondaryItems}</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
setAnalysisResults.innerHTML = html;
}

View file

@ -1776,4 +1776,20 @@ function openInventorySearch() {
window.open('/inventory.html', '_blank'); window.open('/inventory.html', '_blank');
} }
/**
* Opens the Suitbuilder interface in a new browser tab.
*/
function openSuitbuilder() {
// Open the Suitbuilder page in a new tab
window.open('/suitbuilder.html', '_blank');
}
/**
* Opens the Player Debug interface in a new browser tab.
*/
function openPlayerDebug() {
// Open the Player Debug page in a new tab
window.open('/debug.html', '_blank');
}

View file

@ -1240,6 +1240,62 @@ body.noselect, body.noselect * {
margin: -2px -4px; margin: -2px -4px;
} }
.suitbuilder-link {
margin: 0 0 12px;
padding: 8px 12px;
background: var(--card);
border: 1px solid #ff6b4a;
border-radius: 4px;
text-align: center;
}
.suitbuilder-link a {
color: #ff6b4a;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
display: block;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.suitbuilder-link a:hover {
color: #fff;
background: rgba(255, 107, 74, 0.1);
border-radius: 2px;
padding: 2px 4px;
margin: -2px -4px;
}
.debug-link {
margin: 0 0 12px;
padding: 8px 12px;
background: var(--card);
border: 1px solid #4aff6b;
border-radius: 4px;
text-align: center;
}
.debug-link a {
color: #4aff6b;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
display: block;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.debug-link a:hover {
color: #fff;
background: rgba(74, 255, 107, 0.1);
border-radius: 2px;
padding: 2px 4px;
margin: -2px -4px;
}
/* Sortable column styles for inventory tables */ /* Sortable column styles for inventory tables */
.sortable { .sortable {
cursor: pointer; cursor: pointer;