diff --git a/db_async.py b/db_async.py index 998d6707..8a5623e7 100644 --- a/db_async.py +++ b/db_async.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta, timezone from databases import Database from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint -from sqlalchemy.sql import func # Environment: Postgres/TimescaleDB connection URL DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth") @@ -176,20 +175,6 @@ Index( server_health_checks.c.timestamp.desc() ) -character_stats = Table( - "character_stats", - metadata, - Column("character_name", String, primary_key=True, nullable=False), - Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()), - Column("level", Integer, nullable=True), - Column("total_xp", BigInteger, nullable=True), - Column("unassigned_xp", BigInteger, nullable=True), - Column("luminance_earned", BigInteger, nullable=True), - Column("luminance_total", BigInteger, nullable=True), - Column("deaths", Integer, nullable=True), - Column("stats_data", JSON, nullable=False), -) - async def init_db_async(): """Initialize PostgreSQL/TimescaleDB schema and hypertable. @@ -265,26 +250,6 @@ async def init_db_async(): except Exception as e: print(f"Warning: failed to create portal table constraints: {e}") - # Ensure character_stats table exists with JSONB column type - try: - with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: - conn.execute(text(""" - CREATE TABLE IF NOT EXISTS character_stats ( - character_name VARCHAR(255) PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - level INTEGER, - total_xp BIGINT, - unassigned_xp BIGINT, - luminance_earned BIGINT, - luminance_total BIGINT, - deaths INTEGER, - stats_data JSONB NOT NULL - ) - """)) - print("character_stats table created/verified successfully") - except Exception as e: - print(f"Warning: failed to create character_stats table: {e}") - async def cleanup_old_portals(): """Clean up portals older than 1 hour.""" try: diff --git a/docs/plans/2026-02-26-character-stats-design.md b/docs/plans/2026-02-26-character-stats-design.md deleted file mode 100644 index 0efd8967..00000000 --- a/docs/plans/2026-02-26-character-stats-design.md +++ /dev/null @@ -1,308 +0,0 @@ -# Character Stats Window - Design Document - -## Overview - -Add a live character stats window to the Dereth Tracker map interface, styled as an Asheron's Call game UI replica. Accessible via a "Char" button on each player in the list, alongside the existing Chat, Stats, and Inventory buttons. - -**Scope:** MosswartOverlord only (database, backend, frontend). The plugin implementation is a separate follow-up with a handoff spec. - ---- - -## Architecture: Single Event + JSONB Table with Indexed Columns - -One new `character_stats` event type from the plugin. Backend stores in a single `character_stats` table with key columns extracted for efficient SQL queries (level, XP, luminance) plus a `stats_data` JSONB column for the full payload. In-memory cache for live display, DB for persistence. - ---- - -## Database Schema - -New table `character_stats`: - -```sql -CREATE TABLE character_stats ( - character_name VARCHAR(255) NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - level INTEGER, - total_xp BIGINT, - unassigned_xp BIGINT, - luminance_earned BIGINT, - luminance_total BIGINT, - deaths INTEGER, - stats_data JSONB NOT NULL, - PRIMARY KEY (character_name) -); -``` - -Single row per character, upserted on each 10-minute update. - -### JSONB `stats_data` Structure - -```json -{ - "attributes": { - "strength": {"base": 290, "creation": 100}, - "endurance": {"base": 200, "creation": 100}, - "coordination": {"base": 240, "creation": 100}, - "quickness": {"base": 220, "creation": 10}, - "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100} - }, - "vitals": { - "health": {"base": 341}, - "stamina": {"base": 400}, - "mana": {"base": 300} - }, - "skills": { - "war_magic": {"base": 533, "training": "Specialized"}, - "melee_defense": {"base": 488, "training": "Specialized"}, - "life_magic": {"base": 440, "training": "Trained"}, - "arcane_lore": {"base": 10, "training": "Untrained"} - }, - "allegiance": { - "name": "Knights of Dereth", - "monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0}, - "patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1}, - "rank": 10, - "followers": 5 - }, - "race": "Aluvian", - "gender": "Male", - "birth": "2018-03-15 14:22:33", - "current_title": 42, - "skill_credits": 0 -} -``` - ---- - -## Backend - -### WebSocket Handler (`main.py`) - -New `character_stats` event type in the `/ws/position` handler. Same pattern as vitals: - -1. **Validate** with `CharacterStatsMessage` Pydantic model -2. **Cache** in `live_character_stats: Dict[str, dict]` for instant access -3. **Persist** to `character_stats` table via upsert (`INSERT ... ON CONFLICT (character_name) DO UPDATE`) -4. **Broadcast** to browser clients via `_broadcast_to_browser_clients()` - -### Pydantic Model - -```python -class CharacterStatsMessage(BaseModel): - character_name: str - timestamp: datetime - level: Optional[int] - total_xp: Optional[int] - unassigned_xp: Optional[int] - luminance_earned: Optional[int] - luminance_total: Optional[int] - deaths: Optional[int] - race: Optional[str] - gender: Optional[str] - birth: Optional[str] - current_title: Optional[int] - skill_credits: Optional[int] - attributes: Optional[dict] - vitals: Optional[dict] - skills: Optional[dict] - allegiance: Optional[dict] -``` - -### HTTP Endpoint - -``` -GET /api/character-stats/{name} -``` - -Returns latest stats for a character. Checks in-memory cache first, falls back to DB. Used when a browser opens a character window after the initial broadcast. - -### Test Endpoint (temporary, for development) - -``` -POST /api/character-stats/test -``` - -Accepts a mock `character_stats` payload, processes it through the same pipeline (cache + DB + broadcast). Allows full end-to-end testing without the plugin running. - ---- - -## Frontend - -### Character Button - -New "Char" button in the player list, same pattern as Chat/Stats/Inventory: - -```javascript -const charBtn = document.createElement('button'); -charBtn.className = 'char-btn'; -charBtn.textContent = 'Char'; -// click -> showCharacterWindow(playerData.character_name) -``` - -### `showCharacterWindow(name)` - -Uses existing `createWindow` helper. Window size: 450x650px (tall and narrow like the game panel). - -**Data loading:** -1. On open, fetch `GET /api/character-stats/{name}` -2. Listen for `character_stats` WebSocket broadcasts to update live -3. Vitals bars update from existing `vitals` WebSocket messages (5-second stream) -4. If no data exists, show "Awaiting character data..." placeholder - -### Window Layout - -Stacked vertically, mimicking the AC character panel: - -1. **Header** - Character name, level, race/gender, title. Gold text on dark background. - -2. **Attributes panel** - 3x2 grid: - ``` - Strength 290 Quickness 220 - Endurance 200 Focus 250 - Coordination 240 Self 200 - ``` - Base values shown, creation values in smaller text. - -3. **Vitals bars** - Red (HP), yellow (Stamina), blue (Mana) bars with current/max numbers. Live-updating from existing vitals stream. - -4. **Skills section** - Scrollable, grouped by training level: - - **Specialized** (gold text) - - **Trained** (white text) - - **Untrained** (grey text) - Each shows skill name + level. - -5. **Allegiance section** - Monarch, patron, rank, followers count. - -6. **Footer** - XP, unassigned XP, luminance, deaths, birth date. - ---- - -## Styling: AC Game UI Replica - -Color palette drawn from the Asheron's Call interface: - -```css ---ac-bg: #1a1410; /* Dark brown/black background */ ---ac-panel: #2a2218; /* Panel background */ ---ac-border: #8b7355; /* Gold/brown borders */ ---ac-header: #d4a843; /* Gold header text */ ---ac-text: #c8b89a; /* Parchment-colored body text */ ---ac-text-dim: #7a6e5e; /* Dimmed/secondary text */ ---ac-specialized: #d4a843; /* Gold for specialized skills */ ---ac-trained: #c8b89a; /* Light for trained */ ---ac-untrained: #5a5248; /* Grey for untrained */ -``` - -Vitals bar colors: -- Health: `#8b1a1a` bg, `#cc3333` fill (red) -- Stamina: `#8b7a1a` bg, `#ccaa33` fill (yellow) -- Mana: `#1a3a8b` bg, `#3366cc` fill (blue) - -Panel styling: -- Subtle inner border with gold/brown -- CSS gradient background to simulate parchment grain (no image files) -- Section dividers as thin gold lines -- Skill rows with subtle hover highlight -- Compact padding (information-dense like the game UI) - ---- - -## Plugin Event Contract - -The plugin will send a `character_stats` message via the existing WebSocket connection: - -- **Frequency:** On login + every 10 minutes -- **Channel:** Existing `/ws/position` WebSocket - -```json -{ - "type": "character_stats", - "timestamp": "2026-02-26T12:34:56Z", - "character_name": "Barris", - "level": 275, - "race": "Aluvian", - "gender": "Male", - "birth": "2018-03-15 14:22:33", - "total_xp": 191226310247, - "unassigned_xp": 0, - "skill_credits": 0, - "luminance_earned": 500000, - "luminance_total": 1500000, - "deaths": 3175, - "current_title": 42, - "attributes": { - "strength": {"base": 290, "creation": 100}, - "endurance": {"base": 200, "creation": 100}, - "coordination": {"base": 240, "creation": 100}, - "quickness": {"base": 220, "creation": 10}, - "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100} - }, - "vitals": { - "health": {"base": 341}, - "stamina": {"base": 400}, - "mana": {"base": 300} - }, - "skills": { - "war_magic": {"base": 533, "training": "Specialized"}, - "melee_defense": {"base": 488, "training": "Specialized"}, - "life_magic": {"base": 440, "training": "Trained"}, - "arcane_lore": {"base": 10, "training": "Untrained"} - }, - "allegiance": { - "name": "Knights of Dereth", - "monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0}, - "patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1}, - "rank": 10, - "followers": 5 - } -} -``` - ---- - -## Data Flow - -``` -Plugin (every 10 min + on login) - │ character_stats JSON via /ws/position - ▼ -Backend handler - │ Pydantic validation - ├──▶ live_character_stats cache (in-memory) - ├──▶ character_stats table (upsert) - └──▶ _broadcast_to_browser_clients() - │ - ▼ -/ws/live → Browser - │ message.type === 'character_stats' - ▼ -Character window updates live - -Browser can also fetch on demand: -GET /api/character-stats/{name} → cache → DB fallback -``` - -Vitals (HP/Stam/Mana) update separately via existing 5-second vitals stream. - ---- - -## Files Modified - -| File | Changes | -|------|---------| -| `main.py` | New `character_stats` handler, Pydantic model, in-memory cache, HTTP endpoint, test endpoint | -| `db_async.py` | New `character_stats` table definition | -| `static/script.js` | New "Char" button, `showCharacterWindow()`, WebSocket listener for `character_stats` | -| `static/style.css` | AC-themed character window styles | - ---- - -## What's NOT in Scope - -- Plugin implementation (separate follow-up with handoff spec) -- Historical stat tracking over time (table supports it but no UI yet) -- Skill icons from the game (text-only for v1) -- Title name resolution (show title ID, not name) -- Vassal list display (just monarch/patron/rank/followers) diff --git a/docs/plans/2026-02-26-character-stats-plan.md b/docs/plans/2026-02-26-character-stats-plan.md deleted file mode 100644 index 4f52b78d..00000000 --- a/docs/plans/2026-02-26-character-stats-plan.md +++ /dev/null @@ -1,1119 +0,0 @@ -# Character Stats Window - Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a live character stats window (AC game UI replica) to the Dereth Tracker map, with database persistence, WebSocket streaming, and an HTTP API. - -**Architecture:** New `character_stats` event type flows through the existing plugin WebSocket → backend handler → in-memory cache + DB persist → broadcast to browsers. Frontend opens a draggable window styled like the AC character panel. A test endpoint allows development without the plugin. - -**Tech Stack:** Python/FastAPI (backend), SQLAlchemy + PostgreSQL (DB), vanilla JavaScript (frontend), CSS (AC-themed styling) - -**CRITICAL:** Do NOT push to git until manual testing is complete. - ---- - -### Task 1: Add character_stats table to database - -**Files:** -- Modify: `db_async.py` (add table after line 169, the last table definition) -- Modify: `main.py` (add table creation to startup) - -**Step 1: Add table definition to db_async.py** - -After the `server_status` table definition (line 169), add: - -```python -character_stats = Table( - "character_stats", - metadata, - Column("character_name", String, primary_key=True, nullable=False), - Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()), - Column("level", Integer, nullable=True), - Column("total_xp", BigInteger, nullable=True), - Column("unassigned_xp", BigInteger, nullable=True), - Column("luminance_earned", BigInteger, nullable=True), - Column("luminance_total", BigInteger, nullable=True), - Column("deaths", Integer, nullable=True), - Column("stats_data", JSON, nullable=False), -) -``` - -Make sure the necessary imports exist at the top of db_async.py: `BigInteger`, `JSON`, and `func` from sqlalchemy. Check which are already imported and add any missing ones. - -**Step 2: Add table creation to main.py startup** - -Find the `startup` event handler in main.py where tables are created (look for `CREATE TABLE IF NOT EXISTS` statements or `metadata.create_all`). Add the `character_stats` table creation alongside the existing tables. - -If tables are created via raw SQL, add: - -```sql -CREATE TABLE IF NOT EXISTS character_stats ( - character_name VARCHAR(255) PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - level INTEGER, - total_xp BIGINT, - unassigned_xp BIGINT, - luminance_earned BIGINT, - luminance_total BIGINT, - deaths INTEGER, - stats_data JSONB NOT NULL -); -``` - -**Step 3: Verify** - -Rebuild and restart the container: -```bash -docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker -``` - -Check logs for any startup errors: -```bash -docker logs mosswartoverlord-dereth-tracker-1 --tail 50 -``` - -**Step 4: Commit** - -```bash -git add db_async.py main.py -git commit -m "Add character_stats table for persistent character data storage" -``` - ---- - -### Task 2: Add Pydantic model and WebSocket handler for character_stats - -**Files:** -- Modify: `main.py` (Pydantic model near line 874, handler in /ws/position, in-memory cache near line 780) - -**Step 1: Add Pydantic model after VitalsMessage (line ~874)** - -```python -class CharacterStatsMessage(BaseModel): - """ - Model for the character_stats WebSocket message type. - Contains character attributes, skills, allegiance, and progression data. - Sent by plugin on login and every 10 minutes. - """ - character_name: str - timestamp: datetime - level: Optional[int] = None - total_xp: Optional[int] = None - unassigned_xp: Optional[int] = None - luminance_earned: Optional[int] = None - luminance_total: Optional[int] = None - deaths: Optional[int] = None - race: Optional[str] = None - gender: Optional[str] = None - birth: Optional[str] = None - current_title: Optional[int] = None - skill_credits: Optional[int] = None - attributes: Optional[dict] = None - vitals: Optional[dict] = None - skills: Optional[dict] = None - allegiance: Optional[dict] = None -``` - -Make sure `Optional` is imported from `typing` (check existing imports). - -**Step 2: Add in-memory cache near live_vitals (line ~780)** - -```python -live_character_stats: Dict[str, dict] = {} -``` - -**Step 3: Add handler in /ws/position WebSocket** - -Find the vitals handler block (line ~1954). After the vitals `continue` statement and before the quest handler, add: - -```python -if msg_type == "character_stats": - payload = data.copy() - payload.pop("type", None) - try: - stats_msg = CharacterStatsMessage.parse_obj(payload) - stats_dict = stats_msg.dict() - - # Cache in memory - live_character_stats[stats_msg.character_name] = stats_dict - - # Build stats_data JSONB (everything except extracted columns) - stats_data = {} - for key in ("attributes", "vitals", "skills", "allegiance", - "race", "gender", "birth", "current_title", "skill_credits"): - if stats_dict.get(key) is not None: - stats_data[key] = stats_dict[key] - - # Upsert to database - upsert_query = text(""" - INSERT INTO character_stats - (character_name, timestamp, level, total_xp, unassigned_xp, - luminance_earned, luminance_total, deaths, stats_data) - VALUES - (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, - :luminance_earned, :luminance_total, :deaths, :stats_data) - ON CONFLICT (character_name) DO UPDATE SET - timestamp = EXCLUDED.timestamp, - level = EXCLUDED.level, - total_xp = EXCLUDED.total_xp, - unassigned_xp = EXCLUDED.unassigned_xp, - luminance_earned = EXCLUDED.luminance_earned, - luminance_total = EXCLUDED.luminance_total, - deaths = EXCLUDED.deaths, - stats_data = EXCLUDED.stats_data - """) - await database.execute(upsert_query, { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }) - - # Broadcast to browser clients - await _broadcast_to_browser_clients(data) - logger.info(f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}") - except Exception as e: - logger.error(f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", exc_info=True) - continue -``` - -Make sure `text` is imported from `sqlalchemy` and `json` is imported. Check existing imports. - -**Step 4: Verify** - -Rebuild and restart: -```bash -docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker -``` - -Check for startup errors: -```bash -docker logs mosswartoverlord-dereth-tracker-1 --tail 50 -``` - -**Step 5: Commit** - -```bash -git add main.py -git commit -m "Add character_stats WebSocket handler with DB persistence and broadcast" -``` - ---- - -### Task 3: Add HTTP API endpoint and test endpoint - -**Files:** -- Modify: `main.py` (add endpoints near other GET endpoints, around line 1157) - -**Step 1: Add GET endpoint for character stats** - -Near the other HTTP endpoints (after `/live` endpoint around line 1165), add: - -```python -@app.get("/api/character-stats/{name}") -async def get_character_stats(name: str): - """Return latest character stats. Checks in-memory cache first, falls back to DB.""" - try: - # Try in-memory cache first - if name in live_character_stats: - return JSONResponse(content=jsonable_encoder(live_character_stats[name])) - - # Fall back to database - query = text("SELECT * FROM character_stats WHERE character_name = :name") - row = await database.fetch_one(query, {"name": name}) - if row: - result = dict(row._mapping) - # Parse stats_data back from JSONB - if isinstance(result.get("stats_data"), str): - result["stats_data"] = json.loads(result["stats_data"]) - # Merge stats_data fields into top level for frontend compatibility - stats_data = result.pop("stats_data", {}) - result.update(stats_data) - return JSONResponse(content=jsonable_encoder(result)) - - return JSONResponse(content={"error": "No stats available for this character"}, status_code=404) - except Exception as e: - logger.error(f"Failed to get character stats for {name}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") -``` - -**Step 2: Add test endpoint for development** - -```python -@app.post("/api/character-stats/test") -async def test_character_stats(): - """Inject mock character_stats data for frontend development. - Processes through the same pipeline as real plugin data.""" - mock_data = { - "type": "character_stats", - "timestamp": datetime.utcnow().isoformat() + "Z", - "character_name": "TestCharacter", - "level": 275, - "race": "Aluvian", - "gender": "Male", - "birth": "2018-03-15 14:22:33", - "total_xp": 191226310247, - "unassigned_xp": 4500000, - "skill_credits": 2, - "luminance_earned": 500000, - "luminance_total": 1500000, - "deaths": 3175, - "current_title": 42, - "attributes": { - "strength": {"base": 290, "creation": 100}, - "endurance": {"base": 200, "creation": 100}, - "coordination": {"base": 240, "creation": 100}, - "quickness": {"base": 220, "creation": 10}, - "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100} - }, - "vitals": { - "health": {"base": 341}, - "stamina": {"base": 400}, - "mana": {"base": 300} - }, - "skills": { - "war_magic": {"base": 533, "training": "Specialized"}, - "life_magic": {"base": 440, "training": "Specialized"}, - "creature_enchantment": {"base": 430, "training": "Specialized"}, - "item_enchantment": {"base": 420, "training": "Specialized"}, - "void_magic": {"base": 510, "training": "Specialized"}, - "melee_defense": {"base": 488, "training": "Specialized"}, - "missile_defense": {"base": 470, "training": "Specialized"}, - "magic_defense": {"base": 460, "training": "Specialized"}, - "two_handed_combat": {"base": 420, "training": "Specialized"}, - "heavy_weapons": {"base": 410, "training": "Specialized"}, - "finesse_weapons": {"base": 400, "training": "Trained"}, - "light_weapons": {"base": 390, "training": "Trained"}, - "missile_weapons": {"base": 380, "training": "Trained"}, - "shield": {"base": 350, "training": "Trained"}, - "dual_wield": {"base": 340, "training": "Trained"}, - "arcane_lore": {"base": 330, "training": "Trained"}, - "mana_conversion": {"base": 320, "training": "Trained"}, - "healing": {"base": 300, "training": "Trained"}, - "lockpick": {"base": 280, "training": "Trained"}, - "assess_creature": {"base": 10, "training": "Untrained"}, - "assess_person": {"base": 10, "training": "Untrained"}, - "deception": {"base": 10, "training": "Untrained"}, - "leadership": {"base": 10, "training": "Untrained"}, - "loyalty": {"base": 10, "training": "Untrained"}, - "jump": {"base": 10, "training": "Untrained"}, - "run": {"base": 10, "training": "Untrained"}, - "salvaging": {"base": 10, "training": "Untrained"}, - "cooking": {"base": 10, "training": "Untrained"}, - "fletching": {"base": 10, "training": "Untrained"}, - "alchemy": {"base": 10, "training": "Untrained"}, - "sneak_attack": {"base": 10, "training": "Untrained"}, - "dirty_fighting": {"base": 10, "training": "Untrained"}, - "recklessness": {"base": 10, "training": "Untrained"}, - "summoning": {"base": 10, "training": "Untrained"} - }, - "allegiance": { - "name": "Knights of Dereth", - "monarch": {"name": "HighKing", "race": 1, "rank": 0, "gender": 0}, - "patron": {"name": "SirLancelot", "race": 1, "rank": 5, "gender": 0}, - "rank": 8, - "followers": 12 - } - } - - # Process through the same pipeline - payload = mock_data.copy() - payload.pop("type", None) - try: - stats_msg = CharacterStatsMessage.parse_obj(payload) - stats_dict = stats_msg.dict() - live_character_stats[stats_msg.character_name] = stats_dict - - stats_data = {} - for key in ("attributes", "vitals", "skills", "allegiance", - "race", "gender", "birth", "current_title", "skill_credits"): - if stats_dict.get(key) is not None: - stats_data[key] = stats_dict[key] - - upsert_query = text(""" - INSERT INTO character_stats - (character_name, timestamp, level, total_xp, unassigned_xp, - luminance_earned, luminance_total, deaths, stats_data) - VALUES - (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, - :luminance_earned, :luminance_total, :deaths, :stats_data) - ON CONFLICT (character_name) DO UPDATE SET - timestamp = EXCLUDED.timestamp, - level = EXCLUDED.level, - total_xp = EXCLUDED.total_xp, - unassigned_xp = EXCLUDED.unassigned_xp, - luminance_earned = EXCLUDED.luminance_earned, - luminance_total = EXCLUDED.luminance_total, - deaths = EXCLUDED.deaths, - stats_data = EXCLUDED.stats_data - """) - await database.execute(upsert_query, { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }) - - await _broadcast_to_browser_clients(mock_data) - return {"status": "ok", "character_name": stats_msg.character_name} - except Exception as e: - logger.error(f"Test endpoint failed: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) -``` - -**IMPORTANT:** The test endpoint route `/api/character-stats/test` must be registered BEFORE the parameterized route `/api/character-stats/{name}`, otherwise FastAPI will treat "test" as a character name. Place the POST endpoint first. - -**Step 3: Verify** - -Rebuild and restart: -```bash -docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker -``` - -Test the endpoints: -```bash -# Inject mock data -curl -X POST http://localhost:8000/api/character-stats/test - -# Retrieve it -curl http://localhost:8000/api/character-stats/TestCharacter -``` - -Both should return valid JSON with character data. - -**Step 4: Commit** - -```bash -git add main.py -git commit -m "Add character stats HTTP API and test endpoint for development" -``` - ---- - -### Task 4: Add "Char" button to player list - -**Files:** -- Modify: `static/script.js` (createNewListItem function, lines ~145-164) - -**Step 1: Add character button after inventory button** - -In `createNewListItem()`, find where the inventory button is created (lines ~145-160) and appended (line ~164). After the inventory button creation and before the `buttonsContainer.appendChild` calls, add: - -```javascript -const charBtn = document.createElement('button'); -charBtn.className = 'char-btn'; -charBtn.textContent = 'Char'; -charBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - if (playerData) { - showCharacterWindow(playerData.character_name); - } -}); -``` - -Then add `buttonsContainer.appendChild(charBtn);` alongside the other button appends. - -Also store the reference on the list item: `li.charBtn = charBtn;` (same pattern as other buttons). - -**Step 2: Add stub showCharacterWindow function** - -Add a placeholder function (we'll flesh it out in Task 5): - -```javascript -function showCharacterWindow(name) { - debugLog('showCharacterWindow called for:', name); - const windowId = `characterWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Character: ${name}`, 'character-window', - { width: '450px', height: '650px' } - ); - - if (!isNew) { - debugLog('Existing character window found, showing it'); - return; - } - - content.innerHTML = '
Awaiting character data...
'; -} -``` - -Place this near the other show*Window functions (after showInventoryWindow). - -**Step 3: Verify** - -Rebuild container and load the map page. Each player in the list should now have a "Char" button. Clicking it should open a draggable window with "Awaiting character data...". - -**Step 4: Commit** - -```bash -git add static/script.js -git commit -m "Add Char button to player list with stub character window" -``` - ---- - -### Task 5: Build the character window content and AC-themed layout - -**Files:** -- Modify: `static/script.js` (replace stub showCharacterWindow) - -**Step 1: Replace showCharacterWindow with full implementation** - -Replace the stub from Task 4 with the complete function. This is the largest piece of frontend code. - -```javascript -/* ---------- Character stats state -------------------------------- */ -const characterStats = {}; -const characterWindows = {}; - -function showCharacterWindow(name) { - debugLog('showCharacterWindow called for:', name); - const windowId = `characterWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Character: ${name}`, 'character-window' - ); - - if (!isNew) { - debugLog('Existing character window found, showing it'); - return; - } - - win.dataset.character = name; - characterWindows[name] = win; - - // Build the AC-style character panel - content.innerHTML = ` -
-
-
${name}
-
Awaiting character data...
-
- -
-
Attributes
-
-
-
Strength
-
Quickness
-
-
-
Endurance
-
Focus
-
-
-
Coordination
-
Self
-
-
-
- -
-
Vitals
-
-
- Health -
- — / — -
-
- Stamina -
- — / — -
-
- Mana -
- — / — -
-
-
- -
-
Skills
-
-
Awaiting data...
-
-
- -
-
Allegiance
-
-
Awaiting data...
-
-
- - -
- `; - - // Fetch existing data from API - fetch(`${API_BASE}/api/character-stats/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - characterStats[name] = data; - updateCharacterWindow(name, data); - } - }) - .catch(err => handleError('Character stats', err)); - - // If we already have vitals from the live stream, apply them - if (characterVitals[name]) { - updateCharacterVitals(name, characterVitals[name]); - } -} - -function updateCharacterWindow(name, data) { - const escapedName = CSS.escape(name); - - // Header - const header = document.getElementById(`charHeader-${escapedName}`); - if (header) { - const level = data.level || '?'; - const race = data.race || ''; - const gender = data.gender || ''; - const subtitle = [ - `Level ${level}`, - race, - gender - ].filter(Boolean).join(' · '); - header.querySelector('.ac-subtitle').textContent = subtitle; - } - - // Attributes - const attribs = document.getElementById(`charAttribs-${escapedName}`); - if (attribs && data.attributes) { - const order = [ - ['strength', 'quickness'], - ['endurance', 'focus'], - ['coordination', 'self'] - ]; - const rows = attribs.querySelectorAll('.ac-attr-row'); - order.forEach((pair, i) => { - if (rows[i]) { - const cells = rows[i].querySelectorAll('.ac-attr-value'); - pair.forEach((attr, j) => { - if (cells[j] && data.attributes[attr]) { - const val = data.attributes[attr].base || '—'; - const creation = data.attributes[attr].creation; - cells[j].textContent = val; - if (creation !== undefined) { - cells[j].title = `Creation: ${creation}`; - } - } - }); - } - }); - } - - // Skills - const skillsDiv = document.getElementById(`charSkills-${escapedName}`); - if (skillsDiv && data.skills) { - const grouped = { Specialized: [], Trained: [], Untrained: [] }; - for (const [skill, info] of Object.entries(data.skills)) { - const training = info.training || 'Untrained'; - const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - if (grouped[training]) { - grouped[training].push({ name: displayName, base: info.base }); - } - } - // Sort each group by base value descending - for (const group of Object.values(grouped)) { - group.sort((a, b) => b.base - a.base); - } - - let html = ''; - for (const [training, skills] of Object.entries(grouped)) { - if (skills.length === 0) continue; - html += `
`; - html += `
${training}
`; - for (const s of skills) { - html += `
`; - html += `${s.name}`; - html += `${s.base}`; - html += `
`; - } - html += `
`; - } - skillsDiv.innerHTML = html; - } - - // Allegiance - const allegianceDiv = document.getElementById(`charAllegiance-${escapedName}`); - if (allegianceDiv && data.allegiance) { - const a = data.allegiance; - let html = ''; - if (a.name) html += `
Allegiance:${a.name}
`; - if (a.monarch) html += `
Monarch:${a.monarch.name || '—'}
`; - if (a.patron) html += `
Patron:${a.patron.name || '—'}
`; - if (a.rank !== undefined) html += `
Rank:${a.rank}
`; - if (a.followers !== undefined) html += `
Followers:${a.followers}
`; - allegianceDiv.innerHTML = html || '
No allegiance
'; - } - - // Footer - const footer = document.getElementById(`charFooter-${escapedName}`); - if (footer) { - const rows = footer.querySelectorAll('.ac-footer-row'); - const formatNum = n => n != null ? n.toLocaleString() : '—'; - if (rows[0]) rows[0].querySelector('span:last-child').textContent = formatNum(data.total_xp); - if (rows[1]) rows[1].querySelector('span:last-child').textContent = formatNum(data.unassigned_xp); - if (rows[2]) { - const lum = data.luminance_earned != null && data.luminance_total != null - ? `${formatNum(data.luminance_earned)} / ${formatNum(data.luminance_total)}` - : '—'; - rows[2].querySelector('span:last-child').textContent = lum; - } - if (rows[3]) rows[3].querySelector('span:last-child').textContent = formatNum(data.deaths); - } -} - -function updateCharacterVitals(name, vitals) { - const escapedName = CSS.escape(name); - const vitalsDiv = document.getElementById(`charVitals-${escapedName}`); - if (!vitalsDiv) return; - - const vitalElements = vitalsDiv.querySelectorAll('.ac-vital'); - - // Health - if (vitalElements[0]) { - const fill = vitalElements[0].querySelector('.ac-vital-fill'); - const text = vitalElements[0].querySelector('.ac-vital-text'); - if (fill) fill.style.width = `${vitals.health_percentage || 0}%`; - if (text && vitals.health_current !== undefined) { - text.textContent = `${vitals.health_current} / ${vitals.health_max}`; - } - } - // Stamina - if (vitalElements[1]) { - const fill = vitalElements[1].querySelector('.ac-vital-fill'); - const text = vitalElements[1].querySelector('.ac-vital-text'); - if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`; - if (text && vitals.stamina_current !== undefined) { - text.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`; - } - } - // Mana - if (vitalElements[2]) { - const fill = vitalElements[2].querySelector('.ac-vital-fill'); - const text = vitalElements[2].querySelector('.ac-vital-text'); - if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`; - if (text && vitals.mana_current !== undefined) { - text.textContent = `${vitals.mana_current} / ${vitals.mana_max}`; - } - } -} -``` - -**Step 2: Hook into existing WebSocket message handler** - -Find the WebSocket message handler (line ~1834-1850) where `msg.type` is dispatched. Add a case for `character_stats`: - -```javascript -} else if (msg.type === 'character_stats') { - characterStats[msg.character_name] = msg; - updateCharacterWindow(msg.character_name, msg); -``` - -**Step 3: Update existing updateVitalsDisplay to also update character windows** - -Find `updateVitalsDisplay` (line ~1999). At the end of the function, add: - -```javascript -// Also update character window if open -updateCharacterVitals(vitalsMsg.character_name, vitalsMsg); -``` - -**Step 4: Verify** - -Rebuild container. Open map, click "Char" on a player. Window opens with "Awaiting" placeholders. In another terminal: -```bash -curl -X POST http://localhost:8000/api/character-stats/test -``` - -If TestCharacter is in the player list, their character window should populate. If not, verify the GET endpoint returns data: -```bash -curl http://localhost:8000/api/character-stats/TestCharacter -``` - -**Step 5: Commit** - -```bash -git add static/script.js -git commit -m "Add full character window with live stats, vitals, skills, and allegiance display" -``` - ---- - -### Task 6: Add AC-themed CSS for character window - -**Files:** -- Modify: `static/style.css` - -**Step 1: Add character window to base window styles** - -Find the base window style selector (line ~528): -```css -.chat-window, .stats-window, .inventory-window { -``` - -Add `.character-window` to the selector: -```css -.chat-window, .stats-window, .inventory-window, .character-window { -``` - -**Step 2: Add AC-themed character window styles** - -At the end of `static/style.css`, add the full AC theme: - -```css -/* ============================================ - Character Window - AC Game UI Replica - ============================================ */ -.character-window { - width: 450px !important; - height: 650px !important; -} - -.ac-panel { - display: flex; - flex-direction: column; - height: 100%; - background: linear-gradient(135deg, #1a1410 0%, #2a2218 50%, #1a1410 100%); - color: #c8b89a; - font-size: 13px; - overflow-y: auto; -} - -/* Header */ -.ac-header { - padding: 12px 15px; - border-bottom: 1px solid #8b7355; - text-align: center; -} -.ac-name { - font-size: 18px; - font-weight: bold; - color: #d4a843; - letter-spacing: 1px; -} -.ac-subtitle { - font-size: 12px; - color: #7a6e5e; - margin-top: 4px; -} - -/* Sections */ -.ac-section { - padding: 8px 15px; - border-bottom: 1px solid #3a3228; -} -.ac-section-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 2px; - color: #8b7355; - margin-bottom: 6px; - padding-bottom: 3px; - border-bottom: 1px solid #3a3228; -} - -/* Attributes */ -.ac-attributes { - display: flex; - flex-direction: column; - gap: 2px; -} -.ac-attr-row { - display: flex; - gap: 10px; -} -.ac-attr { - flex: 1; - display: flex; - justify-content: space-between; - padding: 3px 8px; - background: rgba(42, 34, 24, 0.6); - border: 1px solid #3a3228; - border-radius: 2px; -} -.ac-attr-label { - color: #7a6e5e; -} -.ac-attr-value { - color: #d4a843; - font-weight: bold; -} - -/* Vitals */ -.ac-vitals { - display: flex; - flex-direction: column; - gap: 6px; -} -.ac-vital { - display: flex; - align-items: center; - gap: 8px; -} -.ac-vital-label { - width: 55px; - font-size: 12px; - color: #7a6e5e; -} -.ac-vital-bar { - flex: 1; - height: 16px; - border-radius: 2px; - overflow: hidden; - position: relative; -} -.ac-vital-fill { - height: 100%; - transition: width 0.5s ease; - border-radius: 2px; -} -.ac-health-bar { background: #4a1a1a; } -.ac-health-bar .ac-vital-fill { background: #cc3333; width: 0%; } -.ac-stamina-bar { background: #4a3a1a; } -.ac-stamina-bar .ac-vital-fill { background: #ccaa33; width: 0%; } -.ac-mana-bar { background: #1a2a4a; } -.ac-mana-bar .ac-vital-fill { background: #3366cc; width: 0%; } -.ac-vital-text { - width: 80px; - text-align: right; - font-size: 12px; - color: #c8b89a; -} - -/* Skills */ -.ac-skills-section { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} -.ac-skills { - overflow-y: auto; - max-height: 200px; - flex: 1; -} -.ac-skill-group { - margin-bottom: 4px; -} -.ac-skill-group-title { - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 1px; - padding: 3px 8px; - margin-bottom: 1px; -} -.ac-skill-group-title.ac-specialized { color: #d4a843; } -.ac-skill-group-title.ac-trained { color: #c8b89a; } -.ac-skill-group-title.ac-untrained { color: #5a5248; } - -.ac-skill-row { - display: flex; - justify-content: space-between; - padding: 2px 12px; - border-bottom: 1px solid rgba(58, 50, 40, 0.4); -} -.ac-skill-row:hover { - background: rgba(139, 115, 85, 0.1); -} -.ac-skill-row.ac-specialized .ac-skill-name { color: #d4a843; } -.ac-skill-row.ac-specialized .ac-skill-value { color: #d4a843; font-weight: bold; } -.ac-skill-row.ac-trained .ac-skill-name { color: #c8b89a; } -.ac-skill-row.ac-trained .ac-skill-value { color: #c8b89a; } -.ac-skill-row.ac-untrained .ac-skill-name { color: #5a5248; } -.ac-skill-row.ac-untrained .ac-skill-value { color: #5a5248; } - -.ac-skill-name { font-size: 12px; } -.ac-skill-value { font-size: 12px; font-family: monospace; } -.ac-skill-placeholder { color: #5a5248; font-style: italic; padding: 8px; } - -/* Allegiance */ -.ac-allegiance { - display: flex; - flex-direction: column; - gap: 2px; -} -.ac-alleg-row { - display: flex; - justify-content: space-between; - padding: 2px 8px; -} -.ac-alleg-row span:first-child { color: #7a6e5e; } -.ac-alleg-row span:last-child { color: #c8b89a; } - -/* Footer */ -.ac-footer { - padding: 8px 15px; - border-top: 1px solid #8b7355; - background: rgba(26, 20, 16, 0.8); -} -.ac-footer-row { - display: flex; - justify-content: space-between; - padding: 2px 0; - font-size: 12px; -} -.ac-footer-row span:first-child { color: #7a6e5e; } -.ac-footer-row span:last-child { color: #d4a843; } - -/* Char button in player list */ -.char-btn { - background: #2a2218; - color: #d4a843; - border: 1px solid #8b7355; - padding: 2px 6px; - border-radius: 3px; - cursor: pointer; - font-size: 11px; -} -.char-btn:hover { - background: #3a3228; - border-color: #d4a843; -} -``` - -**Step 3: Verify** - -Rebuild container. Open map, click "Char" on a player. Window should have the dark AC theme with gold accents. Inject test data: -```bash -curl -X POST http://localhost:8000/api/character-stats/test -``` - -Check that: -- Attributes show in a 3x2 grid with gold values -- Vitals have colored bars (red/yellow/blue) -- Skills are grouped by Specialized (gold), Trained (light), Untrained (grey) -- Footer shows XP, luminance, deaths in gold text -- Window is scrollable if content overflows - -**Step 4: Commit** - -```bash -git add static/style.css -git commit -m "Add AC game UI replica styling for character stats window" -``` - ---- - -### Task 7: Wire up live vitals to character window and final polish - -**Files:** -- Modify: `static/script.js` (minor wiring) - -**Step 1: Handle test endpoint data for any online character** - -The test endpoint sends data for "TestCharacter" which may not be online. To test with a real player, update the test endpoint to accept a character name parameter. Modify the test endpoint in `main.py`: - -Change the test endpoint signature to: -```python -@app.post("/api/character-stats/test/{name}") -async def test_character_stats(name: str): -``` - -And replace `"character_name": "TestCharacter"` with `"character_name": name` in the mock_data. - -Also keep the original parameterless version that defaults to "TestCharacter": -```python -@app.post("/api/character-stats/test") -async def test_character_stats_default(): - return await test_character_stats("TestCharacter") -``` - -**IMPORTANT:** Both test routes must be registered BEFORE the `GET /api/character-stats/{name}` route. - -**Step 2: Verify end-to-end with a live player** - -Rebuild container. Open the map, find an online player (e.g. "Barris"). In another terminal: -```bash -curl -X POST http://localhost:8000/api/character-stats/test/Barris -``` - -Then click "Char" on Barris in the player list. The window should: -1. Open with AC theme -2. Show all mock attributes, skills, allegiance -3. Show live vitals bars updating every 5 seconds (from existing vitals stream) - -**Step 3: Commit** - -```bash -git add main.py static/script.js -git commit -m "Add parameterized test endpoint and final character window wiring" -``` - ---- - -### Task 8: Full manual testing pass - -**CRITICAL: Do not push to git until ALL tests pass.** - -**Test Checklist:** - -| # | Test | How to Verify | -|---|------|---------------| -| 1 | Container starts | No errors in `docker logs` | -| 2 | Char button appears | Each player in list has "Char" button alongside Chat/Stats/Inventory | -| 3 | Window opens | Click Char → draggable AC-themed window appears | -| 4 | Empty state | Window shows "Awaiting character data..." when no stats exist | -| 5 | Test endpoint | `curl -X POST .../api/character-stats/test` returns 200 OK | -| 6 | GET endpoint | `curl .../api/character-stats/TestCharacter` returns full stats JSON | -| 7 | Window populates | After test POST, open window shows attributes/skills/allegiance | -| 8 | Live broadcast | Window updates in real-time when test POST fires (no refresh needed) | -| 9 | Vitals bars | HP/Stam/Mana bars update every 5s from existing vitals stream | -| 10 | Skills grouped | Specialized (gold), Trained (white), Untrained (grey) in correct groups | -| 11 | Multiple windows | Open character windows for different players, all work independently | -| 12 | Window z-index | Latest opened/clicked window is on top | -| 13 | Close and reopen | Close window, click Char again → same window reappears | -| 14 | Other features | Chat, Stats, Inventory, pan/zoom, heatmap still work (no regression) | -| 15 | Console clean | No JavaScript errors in browser console | -| 16 | Named test | `curl -X POST .../api/character-stats/test/PlayerName` works for online player | - -**If all pass:** Ready to push. Inform user. - -**If any fail:** Fix, re-test, repeat. - ---- - -## Files Modified Summary - -| File | Changes | -|------|---------| -| `db_async.py` | New `character_stats` table definition | -| `main.py` | Pydantic model, in-memory cache, WebSocket handler, HTTP endpoints, test endpoint | -| `static/script.js` | Char button, showCharacterWindow, updateCharacterWindow, updateCharacterVitals, WebSocket handler | -| `static/style.css` | Full AC-themed character window styles | - ---- - -## Plugin Handoff Spec - -After all tasks pass testing, generate a prompt for the plugin developer describing: -1. The exact JSON contract (from design doc) -2. Send on login + every 10 minutes -3. Use existing WebSocket connection -4. Event type: `character_stats` -5. Data sources: CharacterFilter API for attributes/vitals, FileService.SkillTable for skills, ServerDispatch for allegiance diff --git a/docs/plans/2026-02-26-plugin-character-stats-design.md b/docs/plans/2026-02-26-plugin-character-stats-design.md deleted file mode 100644 index 62614bd6..00000000 --- a/docs/plans/2026-02-26-plugin-character-stats-design.md +++ /dev/null @@ -1,201 +0,0 @@ -# Plugin Character Stats Streaming - Design Document - -## Overview - -Add character stats streaming to the MosswartMassacre Decal plugin. Sends a `character_stats` JSON payload via the existing WebSocket connection to MosswartOverlord on login and every 10 minutes. - -**Scope:** MosswartMassacre plugin only. The backend (MosswartOverlord) already handles this event type. - ---- - -## Architecture - -A new `CharacterStats` class collects data from three sources and sends it via the existing WebSocket: - -1. **CharacterFilter API** — level, XP, deaths, race, gender, birth, attributes, vitals, skills -2. **Network message interception** — allegiance (event 0x0020), luminance & title (event 0x0013) -3. **Separate 10-minute timer** — triggers the send, plus an immediate send on login - -Uses Newtonsoft.Json with anonymous objects (same as existing `SendVitalsAsync`, `SendChatTextAsync`). - ---- - -## Data Sources - -| Data | Decal API | -|------|-----------| -| Level | `CharacterFilter.Level` | -| Deaths | `CharacterFilter.Deaths` | -| Skill Credits | `CharacterFilter.SkillPoints` | -| Total XP | `CharacterFilter.TotalXP` (Int64) | -| Unassigned XP | `CharacterFilter.UnassignedXP` (Int64) | -| Race | `CharacterFilter.Race` (string) | -| Gender | `CharacterFilter.Gender` (string) | -| Birth | `CharacterFilter.Birth` (DateTime) | -| Attributes (6) | `CharacterFilter.Attributes` collection — `.Name`, `.Base`, `.Creation` | -| Vitals (3) base | `CharacterFilter.Vitals` collection — `.Name`, `.Base` | -| Skills (all) | `CharacterFilter.Underlying.get_Skill((eSkillID)fs.SkillTable[i].Id)` — `.Name`, `.Base`, `.Training` | -| Allegiance | `EchoFilter.ServerDispatch` → game event 0x0020 message fields | -| Luminance | `EchoFilter.ServerDispatch` → game event 0x0013 QWORD keys 6, 7 | -| Current Title | `EchoFilter.ServerDispatch` → game event 0x0029 or 0x002b | - -### Skill Access Pattern (from TreeStats reference) - -Skills require COM interop with careful cleanup: - -```csharp -Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService; -Decal.Interop.Filters.SkillInfo skillinfo = null; - -for (int i = 0; i < fs.SkillTable.Length; ++i) -{ - try - { - skillinfo = CoreManager.Current.CharacterFilter.Underlying.get_Skill( - (Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id); - - string name = skillinfo.Name.ToLower().Replace(" ", "_"); - string training = skillinfo.Training.ToString().Substring(6); // Strip "eTrain" prefix - int baseValue = skillinfo.Base; - } - finally - { - if (skillinfo != null) - { - System.Runtime.InteropServices.Marshal.ReleaseComObject(skillinfo); - skillinfo = null; - } - } -} -``` - -### Allegiance Message Processing (from TreeStats reference) - -Game event 0x0020 contains allegiance tree: - -```csharp -void ProcessAllegianceInfo(NetworkMessageEventArgs e) -{ - allegianceName = e.Message.Value("allegianceName"); - allegianceSize = e.Message.Value("allegianceSize"); - followers = e.Message.Value("followers"); - - MessageStruct records = e.Message.Struct("records"); - // Walk tree to find monarch (treeParent == 0) and patron (parent of current char) -} -``` - -### Luminance from Character Properties (from TreeStats reference) - -Game event 0x0013 contains QWORD properties: - -```csharp -// QWORD key 6 = AvailableLuminance (luminance_earned) -// QWORD key 7 = MaximumLuminance (luminance_total) -``` - ---- - -## Data Flow - -``` -Login Complete - ├──▶ Hook EchoFilter.ServerDispatch (allegiance + luminance/title) - ├──▶ Start 10-minute characterStatsTimer - └──▶ Send first stats after 5-second delay (let CharacterFilter populate) - -Every 10 minutes (+ on login): - CharacterStats.CollectAndSend() - ├── CharacterFilter: level, XP, deaths, race, attributes, vitals - ├── FileService + get_Skill(): all skills with training levels - ├── Cached network data: allegiance, luminance, title - ├── Build anonymous object with Newtonsoft.Json - └── WebSocket.SendCharacterStatsAsync(payload) - └── Existing WebSocket → /ws/position → MosswartOverlord - -Network Messages (event-driven, cached for next stats send): - 0x0020 → allegiance name, monarch, patron, rank, followers - 0x0013 → luminance_earned, luminance_total - 0x0029 → current_title -``` - ---- - -## JSON Payload - -Matches the MosswartOverlord backend contract: - -```json -{ - "type": "character_stats", - "timestamp": "2026-02-26T12:34:56Z", - "character_name": "Barris", - "level": 275, - "race": "Aluvian", - "gender": "Male", - "birth": "2018-03-15 14:22:33", - "total_xp": 191226310247, - "unassigned_xp": 0, - "skill_credits": 0, - "luminance_earned": 500000, - "luminance_total": 1500000, - "deaths": 3175, - "current_title": 42, - "attributes": { - "strength": {"base": 290, "creation": 100}, - "endurance": {"base": 200, "creation": 100}, - "coordination": {"base": 240, "creation": 100}, - "quickness": {"base": 220, "creation": 10}, - "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100} - }, - "vitals": { - "health": {"base": 341}, - "stamina": {"base": 400}, - "mana": {"base": 300} - }, - "skills": { - "war_magic": {"base": 533, "training": "Specialized"}, - "melee_defense": {"base": 488, "training": "Specialized"}, - "life_magic": {"base": 440, "training": "Trained"}, - "arcane_lore": {"base": 10, "training": "Untrained"} - }, - "allegiance": { - "name": "Knights of Dereth", - "monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0}, - "patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1}, - "rank": 10, - "followers": 5 - } -} -``` - ---- - -## Files Modified - -| File | Changes | -|------|---------| -| New: `CharacterStats.cs` | Data collection, network message processing, allegiance/luminance caching | -| `PluginCore.cs` | Hook `EchoFilter.ServerDispatch`, create 10-min timer, initial send on login, cleanup on shutdown | -| `WebSocket.cs` | Add `SendCharacterStatsAsync()` method | - ---- - -## Implementation Details - -- **Skill COM cleanup**: Every `SkillInfo` from `get_Skill()` must be released with `Marshal.ReleaseComObject()` in a `finally` block -- **Allegiance timing**: Network message 0x0020 arrives asynchronously after login. First stats send may have null allegiance; subsequent sends will include it -- **Login delay**: Wait 5 seconds after `LoginComplete` before first send to let CharacterFilter fully populate -- **Timer**: New `System.Timers.Timer` at 600,000ms (10 min), separate from vitals timer -- **Error handling**: Try/catch around entire collection — log errors, don't crash the plugin -- **Training string**: `skillinfo.Training.ToString()` returns values like `"eTrainSpecialized"` — strip first 6 chars to get `"Specialized"` - ---- - -## What's NOT in Scope - -- UI changes in the plugin (no new tab/view) -- Vassal list (just monarch + patron + rank + follower count) -- Buffed skill values (base only, matching TreeStats) -- Historical tracking (backend supports it, not a plugin concern) diff --git a/docs/plans/2026-02-26-plugin-character-stats-plan.md b/docs/plans/2026-02-26-plugin-character-stats-plan.md deleted file mode 100644 index 1e95f065..00000000 --- a/docs/plans/2026-02-26-plugin-character-stats-plan.md +++ /dev/null @@ -1,576 +0,0 @@ -# Plugin Character Stats Streaming - Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add character stats streaming to the MosswartMassacre Decal plugin, sending level, XP, attributes, vitals, skills, allegiance, luminance, and title data via WebSocket every 10 minutes. - -**Architecture:** New `CharacterStats.cs` class handles data collection from Decal APIs and network message caching. PluginCore hooks `EchoFilter.ServerDispatch` for allegiance/luminance/title data, creates a 10-minute timer, and sends an initial update on login. WebSocket.cs gets one new send method. - -**Tech Stack:** C# / .NET Framework, Decal Adapter API, Newtonsoft.Json, COM Interop for skill access - -**Codebase:** `/home/erik/MosswartMassacre/` (spawn-detection branch) - -**Reference:** TreeStats plugin at `/home/erik/treestats/Character.cs` for Decal API patterns - ---- - -### Task 1: Add SendCharacterStatsAsync to WebSocket.cs - -**Files:** -- Modify: `MosswartMassacre/WebSocket.cs:293-297` - -**Step 1: Add the send method** - -Add after `SendVitalsAsync` (line 297), following the exact same pattern: - -```csharp -public static async Task SendCharacterStatsAsync(object statsData) -{ - var json = JsonConvert.SerializeObject(statsData); - await SendEncodedAsync(json, CancellationToken.None); -} -``` - -**Step 2: Verify the file compiles** - -Open the solution and verify no syntax errors. The method follows the identical pattern as `SendVitalsAsync` at line 293-297. - -**Step 3: Commit** - -```bash -cd /home/erik/MosswartMassacre -git add MosswartMassacre/WebSocket.cs -git commit -m "feat: add SendCharacterStatsAsync to WebSocket" -``` - ---- - -### Task 2: Create CharacterStats.cs - Data Structures and Network Message Handlers - -This is the core data collection class. We split it into two tasks: this one covers the static data structures and network message processing, the next covers the collection and send logic. - -**Files:** -- Create: `MosswartMassacre/CharacterStats.cs` -- Modify: `MosswartMassacre/MosswartMassacre.csproj:336` (add Compile Include) - -**Step 1: Create CharacterStats.cs with data structures and message handlers** - -Create `/home/erik/MosswartMassacre/MosswartMassacre/CharacterStats.cs`: - -```csharp -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Runtime.InteropServices; -using Decal.Adapter; -using Decal.Adapter.Wrappers; -using Newtonsoft.Json; - -namespace MosswartMassacre -{ - public struct AllegianceInfoRecord - { - public string name; - public int rank; - public int race; - public int gender; - - public AllegianceInfoRecord(string _name, int _rank, int _race, int _gender) - { - name = _name; - rank = _rank; - race = _race; - gender = _gender; - } - } - - public static class CharacterStats - { - // Cached allegiance data (populated from network messages) - private static string allegianceName; - private static int allegianceSize; - private static int followers; - private static AllegianceInfoRecord monarch; - private static AllegianceInfoRecord patron; - private static int allegianceRank; - - // Cached luminance data (populated from network messages) - private static long luminanceEarned = -1; - private static long luminanceTotal = -1; - - // Cached title data (populated from network messages) - private static int currentTitle = -1; - - /// - /// Reset all cached data. Call on plugin init. - /// - internal static void Init() - { - allegianceName = null; - allegianceSize = 0; - followers = 0; - monarch = new AllegianceInfoRecord(); - patron = new AllegianceInfoRecord(); - allegianceRank = 0; - luminanceEarned = -1; - luminanceTotal = -1; - currentTitle = -1; - } - - /// - /// Process game event 0x0020 - Allegiance info. - /// Extracts monarch, patron, rank, followers from the allegiance tree. - /// Reference: TreeStats Character.cs:642-745 - /// - internal static void ProcessAllegianceInfoMessage(NetworkMessageEventArgs e) - { - try - { - allegianceName = e.Message.Value("allegianceName"); - allegianceSize = e.Message.Value("allegianceSize"); - followers = e.Message.Value("followers"); - - monarch = new AllegianceInfoRecord(); - patron = new AllegianceInfoRecord(); - - MessageStruct records = e.Message.Struct("records"); - int currentId = CoreManager.Current.CharacterFilter.Id; - var parentMap = new Dictionary(); - var recordMap = new Dictionary(); - - for (int i = 0; i < records.Count; i++) - { - var record = records.Struct(i); - int charId = record.Value("character"); - int treeParent = record.Value("treeParent"); - - parentMap[charId] = treeParent; - recordMap[charId] = new AllegianceInfoRecord( - record.Value("name"), - record.Value("rank"), - record.Value("race"), - record.Value("gender")); - - // Monarch: treeParent <= 1 - if (treeParent <= 1) - { - monarch = recordMap[charId]; - } - } - - // Patron: parent of current character - if (parentMap.ContainsKey(currentId) && recordMap.ContainsKey(parentMap[currentId])) - { - patron = recordMap[parentMap[currentId]]; - } - - // Our rank from the record - if (recordMap.ContainsKey(currentId)) - { - allegianceRank = recordMap[currentId].rank; - } - } - catch (Exception ex) - { - PluginCore.WriteToChat($"[CharStats] Allegiance processing error: {ex.Message}"); - } - } - - /// - /// Process game event 0x0013 - Character property data. - /// Extracts luminance from QWORD keys 6 and 7. - /// Reference: TreeStats Character.cs:582-640 - /// - internal static void ProcessCharacterPropertyData(NetworkMessageEventArgs e) - { - try - { - MessageStruct props = e.Message.Struct("properties"); - MessageStruct qwords = props.Struct("qwords"); - - for (int i = 0; i < qwords.Count; i++) - { - var tmpStruct = qwords.Struct(i); - long key = tmpStruct.Value("key"); - long value = tmpStruct.Value("value"); - - if (key == 6) // AvailableLuminance - luminanceEarned = value; - else if (key == 7) // MaximumLuminance - luminanceTotal = value; - } - } - catch (Exception ex) - { - PluginCore.WriteToChat($"[CharStats] Property processing error: {ex.Message}"); - } - } - - /// - /// Process game event 0x0029 - Titles list. - /// Extracts current title ID. - /// Reference: TreeStats Character.cs:551-580 - /// - internal static void ProcessTitlesMessage(NetworkMessageEventArgs e) - { - try - { - currentTitle = e.Message.Value("current"); - } - catch (Exception ex) - { - PluginCore.WriteToChat($"[CharStats] Title processing error: {ex.Message}"); - } - } - - /// - /// Process game event 0x002b - Set title (when player changes title). - /// - internal static void ProcessSetTitleMessage(NetworkMessageEventArgs e) - { - try - { - currentTitle = e.Message.Value("title"); - } - catch (Exception ex) - { - PluginCore.WriteToChat($"[CharStats] Set title error: {ex.Message}"); - } - } - - /// - /// Collect all character data and send via WebSocket. - /// Called on login (after delay) and every 10 minutes. - /// - internal static void CollectAndSend() - { - if (!PluginCore.WebSocketEnabled) - return; - - try - { - var cf = CoreManager.Current.CharacterFilter; - var culture = new CultureInfo("en-US"); - - // --- Attributes --- - var attributes = new Dictionary(); - foreach (var attr in cf.Attributes) - { - attributes[attr.Name.ToLower()] = new - { - @base = attr.Base, - creation = attr.Creation - }; - } - - // --- Vitals (base values) --- - var vitals = new Dictionary(); - foreach (var vital in cf.Vitals) - { - vitals[vital.Name.ToLower()] = new - { - @base = vital.Base - }; - } - - // --- Skills --- - var skills = new Dictionary(); - Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService; - if (fs != null) - { - for (int i = 0; i < fs.SkillTable.Length; i++) - { - Decal.Interop.Filters.SkillInfo skillinfo = null; - try - { - skillinfo = cf.Underlying.get_Skill( - (Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id); - - string name = skillinfo.Name.ToLower().Replace(" ", "_"); - string training = skillinfo.Training.ToString(); - // Training enum returns "eTrainSpecialized" etc, strip "eTrain" prefix - if (training.Length > 6) - training = training.Substring(6); - - skills[name] = new - { - @base = skillinfo.Base, - training = training - }; - } - finally - { - if (skillinfo != null) - { - Marshal.ReleaseComObject(skillinfo); - skillinfo = null; - } - } - } - } - - // --- Allegiance --- - object allegiance = null; - if (allegianceName != null) - { - allegiance = new - { - name = allegianceName, - monarch = monarch.name != null ? new - { - name = monarch.name, - race = monarch.race, - rank = monarch.rank, - gender = monarch.gender - } : null, - patron = patron.name != null ? new - { - name = patron.name, - race = patron.race, - rank = patron.rank, - gender = patron.gender - } : null, - rank = allegianceRank, - followers = followers - }; - } - - // --- Build payload --- - var payload = new - { - type = "character_stats", - timestamp = DateTime.UtcNow.ToString("o"), - character_name = cf.Name, - level = cf.Level, - race = cf.Race, - gender = cf.Gender, - birth = cf.Birth.ToString(culture), - total_xp = cf.TotalXP, - unassigned_xp = cf.UnassignedXP, - skill_credits = cf.SkillPoints, - deaths = cf.Deaths, - luminance_earned = luminanceEarned >= 0 ? (long?)luminanceEarned : null, - luminance_total = luminanceTotal >= 0 ? (long?)luminanceTotal : null, - current_title = currentTitle >= 0 ? (int?)currentTitle : null, - attributes = attributes, - vitals = vitals, - skills = skills, - allegiance = allegiance - }; - - _ = WebSocket.SendCharacterStatsAsync(payload); - } - catch (Exception ex) - { - PluginCore.WriteToChat($"[CharStats] Error collecting stats: {ex.Message}"); - } - } - } -} -``` - -**Step 2: Add to .csproj** - -In `MosswartMassacre/MosswartMassacre.csproj`, find line 336 (``) and add before it: - -```xml - -``` - -**Step 3: Verify compilation** - -Build the solution. All Decal APIs used here are the same ones already referenced by PluginCore.cs (CharacterFilter, FileService). The only new interop type is `Decal.Interop.Filters.SkillInfo` which comes from the existing Decal.Interop.Filters reference. - -**Step 4: Commit** - -```bash -cd /home/erik/MosswartMassacre -git add MosswartMassacre/CharacterStats.cs MosswartMassacre/MosswartMassacre.csproj -git commit -m "feat: add CharacterStats data collection and network message handlers" -``` - ---- - -### Task 3: Hook ServerDispatch and Timer in PluginCore.cs - -Wire up the network message interception, 10-minute timer, and initial login send. - -**Files:** -- Modify: `MosswartMassacre/PluginCore.cs` - -**Step 1: Add the character stats timer field** - -At line 66 (after `private static System.Windows.Forms.Timer commandTimer;`), add: - -```csharp - private static Timer characterStatsTimer; -``` - -**Step 2: Hook EchoFilter.ServerDispatch in Startup()** - -In `Startup()`, after line 184 (`CoreManager.Current.WorldFilter.ChangeObject += OnInventoryChange;`), add: - -```csharp - // Subscribe to server messages for allegiance/luminance/title data - Core.EchoFilter.ServerDispatch += EchoFilter_ServerDispatch; -``` - -**Step 3: Initialize CharacterStats and timer in LoginComplete()** - -In `CharacterFilter_LoginComplete()`, after the quest streaming initialization block (after line 404 `WriteToChat("[OK] Quest streaming initialized with full data refresh");`), add: - -```csharp - // Initialize character stats streaming - try - { - CharacterStats.Init(); - - // Start 10-minute character stats timer - characterStatsTimer = new Timer(600000); // 10 minutes - characterStatsTimer.Elapsed += OnCharacterStatsUpdate; - characterStatsTimer.AutoReset = true; - characterStatsTimer.Start(); - - // Send initial stats after 5-second delay (let CharacterFilter populate) - var initialDelay = new Timer(5000); - initialDelay.AutoReset = false; - initialDelay.Elapsed += (s, args) => - { - CharacterStats.CollectAndSend(); - ((Timer)s).Dispose(); - }; - initialDelay.Start(); - - WriteToChat("[OK] Character stats streaming initialized (10-min interval)"); - } - catch (Exception ex) - { - WriteToChat($"[ERROR] Character stats initialization failed: {ex.Message}"); - } -``` - -**Step 4: Add the timer handler and ServerDispatch handler** - -After the `SendVitalsUpdate` method (after line 1162), add: - -```csharp - private static void OnCharacterStatsUpdate(object sender, ElapsedEventArgs e) - { - try - { - CharacterStats.CollectAndSend(); - } - catch (Exception ex) - { - WriteToChat($"[CharStats] Timer error: {ex.Message}"); - } - } - - private void EchoFilter_ServerDispatch(object sender, NetworkMessageEventArgs e) - { - try - { - if (e.Message.Type == 0xF7B0) // Game Event - { - int eventId = (int)e.Message["event"]; - - if (eventId == 0x0020) // Allegiance info - { - CharacterStats.ProcessAllegianceInfoMessage(e); - } - else if (eventId == 0x0013) // Login Character (properties) - { - CharacterStats.ProcessCharacterPropertyData(e); - } - else if (eventId == 0x0029) // Titles list - { - CharacterStats.ProcessTitlesMessage(e); - } - else if (eventId == 0x002b) // Set title - { - CharacterStats.ProcessSetTitleMessage(e); - } - } - } - catch (Exception ex) - { - WriteToChat($"[CharStats] ServerDispatch error: {ex.Message}"); - } - } -``` - -**Step 5: Clean up in Shutdown()** - -In `Shutdown()`, after the quest streaming timer cleanup (after line 285), add: - -```csharp - // Stop and dispose character stats timer - if (characterStatsTimer != null) - { - characterStatsTimer.Stop(); - characterStatsTimer.Elapsed -= OnCharacterStatsUpdate; - characterStatsTimer.Dispose(); - characterStatsTimer = null; - } -``` - -Also in `Shutdown()`, after unsubscribing from inventory events (after line 253), add: - -```csharp - // Unsubscribe from server dispatch - Core.EchoFilter.ServerDispatch -= EchoFilter_ServerDispatch; -``` - -**Step 6: Verify compilation** - -Build the solution. All types used are already available: `NetworkMessageEventArgs` from `Decal.Adapter.Wrappers`, `Timer` from `System.Timers`. - -**Step 7: Commit** - -```bash -cd /home/erik/MosswartMassacre -git add MosswartMassacre/PluginCore.cs -git commit -m "feat: wire up character stats timer, ServerDispatch, and login send" -``` - ---- - -### Task 4: Build, Deploy, and Test End-to-End - -**Step 1: Build the plugin** - -Build the MosswartMassacre solution in Release mode. Copy the output DLL to the Decal plugin directory. - -**Step 2: Test with a running game client** - -1. Launch a game client with the plugin loaded -2. Watch for `[OK] Character stats streaming initialized (10-min interval)` in chat -3. After ~5 seconds, check MosswartOverlord logs for the initial character_stats message: - ```bash - docker logs mosswartoverlord-dereth-tracker-1 2>&1 | grep "character_stats\|character stats" | tail -5 - ``` -4. Open the web interface and click "Char" on the player that sent stats -5. Verify the character window shows real data (level, attributes, skills, etc.) - -**Step 3: Verify allegiance data** - -Allegiance info arrives via a separate network message. It may not be available on the first send but should appear on the 10-minute update. To force it sooner, open the allegiance panel in-game (which triggers the 0x0020 message). - -**Step 4: Verify luminance data** - -Luminance comes from the character property message (0x0013) which fires on login. Check that `luminance_earned` and `luminance_total` appear in the character window. - -**Step 5: Wait for 10-minute update** - -Leave the client running for 10+ minutes and verify a second stats update appears in logs. Verify the character window updates with any changed data. - ---- - -## Files Summary - -| File | Action | Description | -|------|--------|-------------| -| `MosswartMassacre/WebSocket.cs` | Modify | Add `SendCharacterStatsAsync()` | -| `MosswartMassacre/CharacterStats.cs` | Create | Data collection, network message handlers, `CollectAndSend()` | -| `MosswartMassacre/MosswartMassacre.csproj` | Modify | Add `` | -| `MosswartMassacre/PluginCore.cs` | Modify | Timer, ServerDispatch hook, login send, shutdown cleanup | diff --git a/inventory-service/database.py b/inventory-service/database.py index 0592f272..d1c0a502 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -36,11 +36,7 @@ class Item(Base): # Equipment status current_wielded_location = Column(Integer, default=0, index=True) # 0 = not equipped - - # Container/position tracking - container_id = Column(BigInteger, default=0) # Game container object ID (0 = character) - slot = Column(Integer, default=-1) # Slot position within container (-1 = unknown) - + # Item state bonded = Column(Integer, default=0) # 0=Normal, 1=Bonded, 2=Sticky, 4=Destroy on drop attuned = Column(Integer, default=0) # 0=Normal, 1=Attuned diff --git a/inventory-service/main.py b/inventory-service/main.py index 400f5866..564e31dc 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -358,19 +358,7 @@ async def startup(): # Create tables if they don't exist Base.metadata.create_all(engine) - - # Migrate: add container_id and slot columns if missing (added for live inventory) - from sqlalchemy import inspect as sa_inspect - inspector = sa_inspect(engine) - existing_columns = {c['name'] for c in inspector.get_columns('items')} - with engine.begin() as conn: - if 'container_id' not in existing_columns: - conn.execute(sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0")) - logger.info("Migration: added container_id column to items table") - if 'slot' not in existing_columns: - conn.execute(sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1")) - logger.info("Migration: added slot column to items table") - + # Create performance indexes create_indexes(engine) @@ -1357,15 +1345,16 @@ async def process_inventory(inventory: InventoryItem): item_ids = await database.fetch_all(item_ids_query, {"character_name": inventory.character_name}) if item_ids: - db_ids = [row['id'] for row in item_ids] - + id_list = [str(row['id']) for row in item_ids] + id_placeholder = ','.join(id_list) + # Delete from all related tables first - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): - await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), - {"ids": db_ids} - ) + await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_placeholder})") + await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_placeholder})") + await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_placeholder})") + await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_placeholder})") + await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_placeholder})") + await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_placeholder})") # Finally delete from main items table await database.execute( @@ -1412,29 +1401,25 @@ async def process_inventory(inventory: InventoryItem): burden=basic['burden'], has_id_data=basic['has_id_data'], last_id_time=item_data.get('LastIdTime', 0), - + # Equipment status current_wielded_location=basic['current_wielded_location'], - - # Container/position tracking - container_id=item_data.get('ContainerId', 0), - slot=int(item_data.get('IntValues', {}).get('231735296', item_data.get('IntValues', {}).get(231735296, -1))), # Decal Slot_Decal key - + # Item state bonded=basic['bonded'], attuned=basic['attuned'], unique=basic['unique'], - + # Stack/Container properties stack_size=basic['stack_size'], max_stack_size=basic['max_stack_size'], items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, - + # Durability structure=basic['structure'] if basic['structure'] != -1 else None, max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, - + # Special item flags rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, @@ -1551,226 +1536,6 @@ async def process_inventory(inventory: InventoryItem): errors=processing_errors if processing_errors else None ) - -@app.post("/inventory/{character_name}/item", - summary="Upsert a single inventory item", - tags=["Data Processing"]) -async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): - """Process and upsert a single item for a character's inventory.""" - - item_game_id = item.get('Id') or item.get('id') - if item_game_id is None: - raise HTTPException(status_code=400, detail="Item must have an 'Id' or 'id' field") - - processed_count = 0 - error_count = 0 - - async with database.transaction(): - # Delete existing item with this character_name + item_id from all related tables - existing = await database.fetch_all( - "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id} - ) - - if existing: - db_ids = [row['id'] for row in existing] - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): - await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), - {"ids": db_ids} - ) - await database.execute( - "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_game_id} - ) - - # Process and insert the single item using the same logic as process_inventory - try: - properties = extract_item_properties(item) - basic = properties['basic'] - - timestamp = datetime.utcnow() - - item_stmt = sa.insert(Item).values( - character_name=character_name, - item_id=item_game_id, - timestamp=timestamp, - name=basic['name'], - icon=basic['icon'], - object_class=basic['object_class'], - value=basic['value'], - burden=basic['burden'], - has_id_data=basic['has_id_data'], - last_id_time=item.get('LastIdTime', 0), - - # Equipment status - current_wielded_location=basic['current_wielded_location'], - - # Container/position tracking - container_id=item.get('ContainerId', 0), - slot=int(item.get('IntValues', {}).get('231735296', item.get('IntValues', {}).get(231735296, -1))), - - # Item state - bonded=basic['bonded'], - attuned=basic['attuned'], - unique=basic['unique'], - - # Stack/Container properties - stack_size=basic['stack_size'], - max_stack_size=basic['max_stack_size'], - items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, - containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, - - # Durability - structure=basic['structure'] if basic['structure'] != -1 else None, - max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, - - # Special item flags - rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, - lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, - remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, - ).returning(Item.id) - - result = await database.fetch_one(item_stmt) - db_item_id = result['id'] - - # Store combat stats if applicable - combat = properties['combat'] - if any(v != -1 and v != -1.0 for v in combat.values()): - combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( - item_id=db_item_id, - **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) - ) - await database.execute(combat_stmt) - - # Store requirements if applicable - requirements = properties['requirements'] - if any(v not in [-1, None, ''] for v in requirements.values()): - req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( - item_id=db_item_id, - **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) - ) - await database.execute(req_stmt) - - # Store enhancements - enhancements = properties['enhancements'] - enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) - ) - await database.execute(enh_stmt) - - # Store ratings if applicable - ratings = properties['ratings'] - if any(v not in [-1, -1.0, None] for v in ratings.values()): - rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( - item_id=db_item_id, - **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) - ) - await database.execute(rat_stmt) - - # Store spell data if applicable - spells = item.get('Spells', []) - active_spells = item.get('ActiveSpells', []) - all_spells = set(spells + active_spells) - - if all_spells: - await database.execute( - "DELETE FROM item_spells WHERE item_id = :item_id", - {"item_id": db_item_id} - ) - for spell_id in all_spells: - is_active = spell_id in active_spells - spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( - item_id=db_item_id, - spell_id=spell_id, - is_active=is_active - ).on_conflict_do_nothing() - await database.execute(spell_stmt) - - # Store raw data - raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( - item_id=db_item_id, - int_values=item.get('IntValues', {}), - double_values=item.get('DoubleValues', {}), - string_values=item.get('StringValues', {}), - bool_values=item.get('BoolValues', {}), - original_json=item - ).on_conflict_do_update( - index_elements=['item_id'], - set_=dict( - int_values=item.get('IntValues', {}), - double_values=item.get('DoubleValues', {}), - string_values=item.get('StringValues', {}), - bool_values=item.get('BoolValues', {}), - original_json=item - ) - ) - await database.execute(raw_stmt) - - processed_count = 1 - - except Exception as e: - error_msg = f"Error processing item {item_game_id}: {e}" - logger.error(error_msg) - error_count = 1 - raise HTTPException(status_code=500, detail=error_msg) - - logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}") - return {"status": "ok", "processed": processed_count} - - -@app.delete("/inventory/{character_name}/item/{item_id}", - summary="Delete a single inventory item", - tags=["Data Processing"]) -async def delete_inventory_item(character_name: str, item_id: int): - """Delete a single item from a character's inventory.""" - - deleted_count = 0 - - async with database.transaction(): - # Find all DB rows for this character + game item_id - existing = await database.fetch_all( - "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id} - ) - - if existing: - db_ids = [row['id'] for row in existing] - - # Delete from all related tables first - for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', - 'item_enhancements', 'item_ratings', 'item_spells'): - await database.execute( - sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), - {"ids": db_ids} - ) - - # Delete from main items table - await database.execute( - "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", - {"character_name": character_name, "item_id": item_id} - ) - - deleted_count = len(existing) - - logger.info(f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}") - return {"status": "ok", "deleted": deleted_count} - - @app.get("/inventory/{character_name}", summary="Get Character Inventory", description="Retrieve processed inventory data for a specific character with normalized item properties.", @@ -3742,7 +3507,7 @@ async def get_available_items_by_slot( # Debug: let's see how many items Barris actually has first debug_query = f"SELECT COUNT(*) as total FROM items WHERE {char_filter}" debug_result = await database.fetch_one(debug_query, query_params) - logger.debug(f"Total items for query: {debug_result['total']}") + print(f"DEBUG: Total items for query: {debug_result['total']}") # Main query to get items with slot information query = f""" diff --git a/main.py b/main.py index 146e2a00..f71f4dd5 100644 --- a/main.py +++ b/main.py @@ -37,7 +37,6 @@ from db_async import ( spawn_events, rare_events, character_inventories, - character_stats, portals, server_health_checks, server_status, @@ -779,7 +778,6 @@ app = FastAPI() # In-memory store mapping character_name to the most recent telemetry snapshot live_snapshots: Dict[str, dict] = {} live_vitals: Dict[str, dict] = {} -live_character_stats: Dict[str, dict] = {} # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" @@ -876,33 +874,6 @@ class VitalsMessage(BaseModel): vitae: int -class CharacterStatsMessage(BaseModel): - """ - Model for the character_stats WebSocket message type. - Contains character attributes, skills, allegiance, and progression data. - Sent by plugin on login and every 10 minutes. - """ - character_name: str - timestamp: datetime - level: Optional[int] = None - total_xp: Optional[int] = None - unassigned_xp: Optional[int] = None - luminance_earned: Optional[int] = None - luminance_total: Optional[int] = None - deaths: Optional[int] = None - race: Optional[str] = None - gender: Optional[str] = None - birth: Optional[str] = None - current_title: Optional[int] = None - skill_credits: Optional[int] = None - attributes: Optional[dict] = None - vitals: Optional[dict] = None - skills: Optional[dict] = None - allegiance: Optional[dict] = None - properties: Optional[dict] = None # Dict[int, int] — DWORD properties (augs, ratings, etc.) - titles: Optional[list] = None # List[str] — character title names - - @app.on_event("startup") async def on_startup(): """Event handler triggered when application starts up. @@ -1979,38 +1950,6 @@ async def ws_receive_snapshots( except Exception as e: logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue - # --- Inventory delta: single item add/remove/update --- - if msg_type == "inventory_delta": - try: - action = data.get("action") - char_name = data.get("character_name", "unknown") - - if action == "remove": - item_id = data.get("item_id") - if item_id is not None: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.delete( - f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item/{item_id}" - ) - if resp.status_code >= 400: - logger.warning(f"Inventory service returned {resp.status_code} for delta remove item_id={item_id}") - elif action in ("add", "update"): - item = data.get("item") - if item: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item", - json=item - ) - if resp.status_code >= 400: - logger.warning(f"Inventory service returned {resp.status_code} for delta {action}") - - # Broadcast delta to all browser clients - await _broadcast_to_browser_clients(data) - logger.debug(f"Inventory delta ({action}) for {char_name}") - except Exception as e: - logger.error(f"Failed to process inventory delta: {e}", exc_info=True) - continue # --- Vitals message: store character health/stamina/mana and broadcast --- if msg_type == "vitals": payload = data.copy() @@ -2023,62 +1962,6 @@ async def ws_receive_snapshots( except Exception as e: logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue - # --- Character stats message: store character attributes/skills/progression and broadcast --- - if msg_type == "character_stats": - payload = data.copy() - payload.pop("type", None) - try: - stats_msg = CharacterStatsMessage.parse_obj(payload) - stats_dict = stats_msg.dict() - - # Cache in memory - live_character_stats[stats_msg.character_name] = stats_dict - - # Build stats_data JSONB (everything except extracted columns) - stats_data = {} - for key in ("attributes", "vitals", "skills", "allegiance", - "race", "gender", "birth", "current_title", "skill_credits", - "properties", "titles"): - if stats_dict.get(key) is not None: - stats_data[key] = stats_dict[key] - - # Upsert to database - await database.execute( - """ - INSERT INTO character_stats - (character_name, timestamp, level, total_xp, unassigned_xp, - luminance_earned, luminance_total, deaths, stats_data) - VALUES - (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, - :luminance_earned, :luminance_total, :deaths, :stats_data) - ON CONFLICT (character_name) DO UPDATE SET - timestamp = EXCLUDED.timestamp, - level = EXCLUDED.level, - total_xp = EXCLUDED.total_xp, - unassigned_xp = EXCLUDED.unassigned_xp, - luminance_earned = EXCLUDED.luminance_earned, - luminance_total = EXCLUDED.luminance_total, - deaths = EXCLUDED.deaths, - stats_data = EXCLUDED.stats_data - """, - { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }) - - # Broadcast to browser clients - await _broadcast_to_browser_clients(data) - logger.info(f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}") - except Exception as e: - logger.error(f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", exc_info=True) - continue # --- Quest message: update cache and broadcast (no database storage) --- if msg_type == "quest": character_name = data.get("character_name") @@ -2362,170 +2245,6 @@ async def get_stats(character_name: str): logger.error(f"Failed to get stats for character {character_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") -# --- Character Stats API ------------------------------------------- - -@app.post("/character-stats/test") -async def test_character_stats_default(): - """Inject mock character_stats data for frontend development.""" - return await test_character_stats("TestCharacter") - -@app.post("/character-stats/test/{name}") -async def test_character_stats(name: str): - """Inject mock character_stats data for a specific character name. - Processes through the same pipeline as real plugin data.""" - mock_data = { - "type": "character_stats", - "timestamp": datetime.utcnow().isoformat() + "Z", - "character_name": name, - "level": 275, - "race": "Aluvian", - "gender": "Male", - "birth": "2018-03-15 14:22:33", - "total_xp": 191226310247, - "unassigned_xp": 4500000, - "skill_credits": 2, - "luminance_earned": 500000, - "luminance_total": 1500000, - "deaths": 3175, - "current_title": 42, - "attributes": { - "strength": {"base": 290, "creation": 100}, - "endurance": {"base": 200, "creation": 100}, - "coordination": {"base": 240, "creation": 100}, - "quickness": {"base": 220, "creation": 10}, - "focus": {"base": 250, "creation": 100}, - "self": {"base": 200, "creation": 100} - }, - "vitals": { - "health": {"base": 341}, - "stamina": {"base": 400}, - "mana": {"base": 300} - }, - "skills": { - "war_magic": {"base": 533, "training": "Specialized"}, - "life_magic": {"base": 440, "training": "Specialized"}, - "creature_enchantment": {"base": 430, "training": "Specialized"}, - "item_enchantment": {"base": 420, "training": "Specialized"}, - "void_magic": {"base": 510, "training": "Specialized"}, - "melee_defense": {"base": 488, "training": "Specialized"}, - "missile_defense": {"base": 470, "training": "Specialized"}, - "magic_defense": {"base": 460, "training": "Specialized"}, - "two_handed_combat": {"base": 420, "training": "Specialized"}, - "heavy_weapons": {"base": 410, "training": "Specialized"}, - "finesse_weapons": {"base": 400, "training": "Trained"}, - "light_weapons": {"base": 390, "training": "Trained"}, - "missile_weapons": {"base": 380, "training": "Trained"}, - "shield": {"base": 350, "training": "Trained"}, - "dual_wield": {"base": 340, "training": "Trained"}, - "arcane_lore": {"base": 330, "training": "Trained"}, - "mana_conversion": {"base": 320, "training": "Trained"}, - "healing": {"base": 300, "training": "Trained"}, - "lockpick": {"base": 280, "training": "Trained"}, - "assess_creature": {"base": 10, "training": "Untrained"}, - "assess_person": {"base": 10, "training": "Untrained"}, - "deception": {"base": 10, "training": "Untrained"}, - "leadership": {"base": 10, "training": "Untrained"}, - "loyalty": {"base": 10, "training": "Untrained"}, - "jump": {"base": 10, "training": "Untrained"}, - "run": {"base": 10, "training": "Untrained"}, - "salvaging": {"base": 10, "training": "Untrained"}, - "cooking": {"base": 10, "training": "Untrained"}, - "fletching": {"base": 10, "training": "Untrained"}, - "alchemy": {"base": 10, "training": "Untrained"}, - "sneak_attack": {"base": 10, "training": "Untrained"}, - "dirty_fighting": {"base": 10, "training": "Untrained"}, - "recklessness": {"base": 10, "training": "Untrained"}, - "summoning": {"base": 10, "training": "Untrained"} - }, - "allegiance": { - "name": "Knights of Dereth", - "monarch": {"name": "HighKing", "race": 1, "rank": 0, "gender": 0}, - "patron": {"name": "SirLancelot", "race": 1, "rank": 5, "gender": 0}, - "rank": 8, - "followers": 12 - } - } - - # Process through the same pipeline as real data - payload = mock_data.copy() - payload.pop("type", None) - try: - stats_msg = CharacterStatsMessage.parse_obj(payload) - stats_dict = stats_msg.dict() - live_character_stats[stats_msg.character_name] = stats_dict - - stats_data = {} - for key in ("attributes", "vitals", "skills", "allegiance", - "race", "gender", "birth", "current_title", "skill_credits"): - if stats_dict.get(key) is not None: - stats_data[key] = stats_dict[key] - - await database.execute( - """ - INSERT INTO character_stats - (character_name, timestamp, level, total_xp, unassigned_xp, - luminance_earned, luminance_total, deaths, stats_data) - VALUES - (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, - :luminance_earned, :luminance_total, :deaths, :stats_data) - ON CONFLICT (character_name) DO UPDATE SET - timestamp = EXCLUDED.timestamp, - level = EXCLUDED.level, - total_xp = EXCLUDED.total_xp, - unassigned_xp = EXCLUDED.unassigned_xp, - luminance_earned = EXCLUDED.luminance_earned, - luminance_total = EXCLUDED.luminance_total, - deaths = EXCLUDED.deaths, - stats_data = EXCLUDED.stats_data - """, - { - "character_name": stats_msg.character_name, - "timestamp": stats_msg.timestamp, - "level": stats_msg.level, - "total_xp": stats_msg.total_xp, - "unassigned_xp": stats_msg.unassigned_xp, - "luminance_earned": stats_msg.luminance_earned, - "luminance_total": stats_msg.luminance_total, - "deaths": stats_msg.deaths, - "stats_data": json.dumps(stats_data), - }) - - await _broadcast_to_browser_clients(mock_data) - return {"status": "ok", "character_name": stats_msg.character_name} - except Exception as e: - logger.error(f"Test endpoint failed: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/character-stats/{name}") -async def get_character_stats(name: str): - """Return latest character stats. Checks in-memory cache first, falls back to DB.""" - try: - # Try in-memory cache first - if name in live_character_stats: - return JSONResponse(content=jsonable_encoder(live_character_stats[name])) - - # Fall back to database - row = await database.fetch_one( - "SELECT * FROM character_stats WHERE character_name = :name", - {"name": name} - ) - if row: - result = dict(row._mapping) - # Parse stats_data back from JSONB - if isinstance(result.get("stats_data"), str): - result["stats_data"] = json.loads(result["stats_data"]) - # Merge stats_data fields into top level for frontend compatibility - stats_data = result.pop("stats_data", {}) - result.update(stats_data) - return JSONResponse(content=jsonable_encoder(result)) - - return JSONResponse(content={"error": "No stats available for this character"}, status_code=404) - except Exception as e: - logger.error(f"Failed to get character stats for {name}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - # -------------------- static frontend --------------------------- # Custom icon handler that prioritizes clean icons over originals from fastapi.responses import FileResponse diff --git a/static/script.js b/static/script.js index a8033863..ecbf64ac 100644 --- a/static/script.js +++ b/static/script.js @@ -159,21 +159,9 @@ function createNewListItem() { } }); - const charBtn = document.createElement('button'); - charBtn.className = 'char-btn'; - charBtn.textContent = 'Char'; - charBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - if (playerData) { - showCharacterWindow(playerData.character_name); - } - }); - buttonsContainer.appendChild(chatBtn); buttonsContainer.appendChild(statsBtn); buttonsContainer.appendChild(inventoryBtn); - buttonsContainer.appendChild(charBtn); li.appendChild(buttonsContainer); // Store references for easy access @@ -181,7 +169,6 @@ function createNewListItem() { li.chatBtn = chatBtn; li.statsBtn = statsBtn; li.inventoryBtn = inventoryBtn; - li.charBtn = charBtn; return li; } @@ -893,175 +880,6 @@ function updateStatsTimeRange(content, name, timeRange) { } // Show or create an inventory window for a character -/** - * Create a single inventory slot DOM element from item data. - * Used by both initial inventory load and live delta updates. - */ -function createInventorySlot(item) { - const slot = document.createElement('div'); - slot.className = 'inventory-slot'; - slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0); - - // Create layered icon container - const iconContainer = document.createElement('div'); - iconContainer.className = 'item-icon-composite'; - - // Get base icon ID with portal.dat offset - const iconRaw = item.icon || item.Icon || 0; - const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - - // Check for overlay and underlay from enhanced format or legacy format - let overlayIconId = null; - let underlayIconId = null; - - // Enhanced format (inventory service) - check for proper icon overlay/underlay properties - if (item.icon_overlay_id && item.icon_overlay_id > 0) { - overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - if (item.icon_underlay_id && item.icon_underlay_id > 0) { - underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - // Fallback: Enhanced format (inventory service) - check spells object for decal info - if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { - if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { - overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { - underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } else if (item.IntValues) { - // Raw delta format from plugin - IntValues directly on item - if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) { - overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) { - underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } else if (item.item_data) { - // Legacy format - parse item_data - try { - const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; - if (itemData.IntValues) { - if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { - overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { - underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } - } catch (e) { - console.warn('Failed to parse item data for', item.name || item.Name); - } - } - - // Create underlay (bottom layer) - if (underlayIconId) { - const underlayImg = document.createElement('img'); - underlayImg.className = 'icon-underlay'; - underlayImg.src = `/icons/${underlayIconId}.png`; - underlayImg.alt = 'underlay'; - underlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(underlayImg); - } - - // Create base icon (middle layer) - const baseImg = document.createElement('img'); - baseImg.className = 'icon-base'; - baseImg.src = `/icons/${baseIconId}.png`; - baseImg.alt = item.name || item.Name || 'Unknown Item'; - baseImg.onerror = function() { this.src = '/icons/06000133.png'; }; - iconContainer.appendChild(baseImg); - - // Create overlay (top layer) - if (overlayIconId) { - const overlayImg = document.createElement('img'); - overlayImg.className = 'icon-overlay'; - overlayImg.src = `/icons/${overlayIconId}.png`; - overlayImg.alt = 'overlay'; - overlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(overlayImg); - } - - // Create tooltip data (handle both inventory-service format and raw plugin format) - const itemName = item.name || item.Name || 'Unknown Item'; - slot.dataset.name = itemName; - slot.dataset.value = item.value || item.Value || 0; - slot.dataset.burden = item.burden || item.Burden || 0; - - // Store enhanced data for tooltips - if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { - const enhancedData = {}; - const possibleProps = [ - 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', - 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', - 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', - 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', - 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', - 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', - 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', - 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', - 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' - ]; - possibleProps.forEach(prop => { - if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { - enhancedData[prop] = item[prop]; - } - }); - slot.dataset.enhancedData = JSON.stringify(enhancedData); - } else { - slot.dataset.enhancedData = JSON.stringify({}); - } - - // Add tooltip on hover - slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mouseleave', hideInventoryTooltip); - - slot.appendChild(iconContainer); - return slot; -} - -/** - * Handle live inventory delta updates from WebSocket. - * Updates the inventory grid for a character if their inventory window is open. - */ -function updateInventoryLive(delta) { - const name = delta.character_name; - const win = inventoryWindows[name]; - if (!win) return; // No inventory window open for this character - - const grid = win.querySelector('.inventory-grid'); - if (!grid) return; - - if (delta.action === 'remove') { - const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id)); - const existing = grid.querySelector(`[data-item-id="${itemId}"]`); - if (existing) existing.remove(); - } else if (delta.action === 'add') { - const newSlot = createInventorySlot(delta.item); - grid.appendChild(newSlot); - } else if (delta.action === 'update') { - const itemId = delta.item.Id || delta.item.id || delta.item.item_id; - const existing = grid.querySelector(`[data-item-id="${itemId}"]`); - if (existing) { - const newSlot = createInventorySlot(delta.item); - existing.replaceWith(newSlot); - } else { - const newSlot = createInventorySlot(delta.item); - grid.appendChild(newSlot); - } - } - - // Update item count - const countEl = win.querySelector('.inventory-count'); - if (countEl) { - const slotCount = grid.querySelectorAll('.inventory-slot').length; - countEl.textContent = `${slotCount} items`; - } -} - function showInventoryWindow(name) { debugLog('showInventoryWindow called for:', name); const windowId = `inventoryWindow-${name}`; @@ -1106,7 +924,139 @@ function showInventoryWindow(name) { // Render each item data.items.forEach(item => { - grid.appendChild(createInventorySlot(item)); + + const slot = document.createElement('div'); + slot.className = 'inventory-slot'; + + // Create layered icon container + const iconContainer = document.createElement('div'); + iconContainer.className = 'item-icon-composite'; + + // Get base icon ID with portal.dat offset + const baseIconId = (item.icon + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + + // Check for overlay and underlay from enhanced format or legacy format + let overlayIconId = null; + let underlayIconId = null; + + // Enhanced format (inventory service) - check for proper icon overlay/underlay properties + if (item.icon_overlay_id && item.icon_overlay_id > 0) { + overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + if (item.icon_underlay_id && item.icon_underlay_id > 0) { + underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + // Fallback: Enhanced format (inventory service) - check spells object for decal info + if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { + // Icon overlay (using the actual property names from the data) + // Only use valid icon IDs (must be > 100 to avoid invalid small IDs) + if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { + overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + // Icon underlay + if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { + underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + } else if (item.item_data) { + // Legacy format - parse item_data + try { + const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; + + if (itemData.IntValues) { + // Icon overlay (ID 218103849) - only use valid icon IDs + if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { + overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + // Icon underlay (ID 218103850) - only use valid icon IDs + if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { + underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + } + } catch (e) { + console.warn('Failed to parse item data for', item.name); + } + } + + // Create underlay (bottom layer) + if (underlayIconId) { + const underlayImg = document.createElement('img'); + underlayImg.className = 'icon-underlay'; + underlayImg.src = `/icons/${underlayIconId}.png`; + underlayImg.alt = 'underlay'; + underlayImg.onerror = function() { this.style.display = 'none'; }; + iconContainer.appendChild(underlayImg); + } + + // Create base icon (middle layer) + const baseImg = document.createElement('img'); + baseImg.className = 'icon-base'; + baseImg.src = `/icons/${baseIconId}.png`; + baseImg.alt = item.name || 'Unknown Item'; + baseImg.onerror = function() { + // Final fallback + this.src = '/icons/06000133.png'; + }; + iconContainer.appendChild(baseImg); + + // Create overlay (top layer) + if (overlayIconId) { + const overlayImg = document.createElement('img'); + overlayImg.className = 'icon-overlay'; + overlayImg.src = `/icons/${overlayIconId}.png`; + overlayImg.alt = 'overlay'; + overlayImg.onerror = function() { this.style.display = 'none'; }; + iconContainer.appendChild(overlayImg); + } + + // Create tooltip data + slot.dataset.name = item.name || 'Unknown Item'; + slot.dataset.value = item.value || 0; + slot.dataset.burden = item.burden || 0; + + // Store enhanced data for tooltips + // All data now comes from inventory service (no more local fallback) + if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { + // Inventory service provides clean, structured data with translations + // Only include properties that actually exist on the item + const enhancedData = {}; + + // Check all possible enhanced properties from inventory service + const possibleProps = [ + 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', + 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', + 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', + 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', + 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', + 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', + 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', + 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', + 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' + ]; + + // Only add properties that exist and have meaningful values + possibleProps.forEach(prop => { + if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { + enhancedData[prop] = item[prop]; + } + }); + + slot.dataset.enhancedData = JSON.stringify(enhancedData); + } else { + // No enhanced data available + slot.dataset.enhancedData = JSON.stringify({}); + } + + // Add tooltip on hover + slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); + slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); + slot.addEventListener('mouseleave', hideInventoryTooltip); + + slot.appendChild(iconContainer); + grid.appendChild(slot); }); invContent.appendChild(grid); @@ -1125,450 +1075,6 @@ function showInventoryWindow(name) { debugLog('Inventory window created for:', name); } -// === TreeStats Property ID Mappings === -const TS_AUGMENTATIONS = { - 218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement", - 221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will", - 224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence", - 227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule", - 230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment", - 233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune", - 236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance", - 239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner", - 241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler", - 244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless", - 294: "Master of the Steel Circle", 295: "Master of the Focused Eye", - 296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer", - 298: "Iron Skin of the Invincible", 299: "Jack of All Trades", - 300: "Infused Void Magic", 301: "Infused War Magic", - 302: "Infused Life Magic", 309: "Infused Item Magic", - 310: "Infused Creature Magic", 326: "Clutch of the Miser", - 328: "Enduring Enchantment" -}; -const TS_AURAS = { - 333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution", - 336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow", - 340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization", - 365: "World" -}; -const TS_RATINGS = { - 370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance", - 374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost", - 379: "Vitality" -}; -const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" }; -const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" }; -const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" }; -const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" }; - -function _tsSocietyRank(v) { - if (v >= 1001) return "Master"; - if (v >= 301) return "Lord"; - if (v >= 151) return "Knight"; - if (v >= 31) return "Adept"; - return "Initiate"; -} - -function _tsSetupTabs(container) { - const tabs = container.querySelectorAll('.ts-tab'); - const boxes = container.querySelectorAll('.ts-box'); - tabs.forEach((tab, i) => { - tab.addEventListener('click', () => { - tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); }); - boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); }); - tab.classList.remove('inactive'); tab.classList.add('active'); - if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); } - }); - }); -} - -function showCharacterWindow(name) { - debugLog('showCharacterWindow called for:', name); - const windowId = `characterWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Character: ${name}`, 'character-window' - ); - - if (!isNew) { - debugLog('Existing character window found, showing it'); - return; - } - - win.dataset.character = name; - characterWindows[name] = win; - - const esc = CSS.escape(name); - content.innerHTML = ` -
-

${name}

-
Awaiting character data...
-
-
-
Total XP: \u2014
-
Unassigned XP: \u2014
-
Luminance: \u2014
-
Deaths: \u2014
-
-
-
-
-
Attributes
-
Skills
-
Titles
-
-
-
-
- Health -
- \u2014 / \u2014 -
-
- Stamina -
- \u2014 / \u2014 -
-
- Mana -
- \u2014 / \u2014 -
-
- - - - - - - - -
AttributeCreationBase
Strength\u2014\u2014
Endurance\u2014\u2014
Coordination\u2014\u2014
Quickness\u2014\u2014
Focus\u2014\u2014
Self\u2014\u2014
- - - - - -
VitalBase
Health\u2014
Stamina\u2014
Mana\u2014
- - -
Skill Credits\u2014
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
-
-
Augmentations
-
Ratings
-
Other
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
-
-
Allegiance
-
Awaiting data...
-
- `; - - // Wire up tab switching - const leftTabs = document.getElementById(`charTabLeft-${esc}`); - const rightTabs = document.getElementById(`charTabRight-${esc}`); - if (leftTabs) _tsSetupTabs(leftTabs); - if (rightTabs) _tsSetupTabs(rightTabs); - - // Fetch existing data from API - fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - characterStats[name] = data; - updateCharacterWindow(name, data); - } - }) - .catch(err => handleError('Character stats', err)); - - // If we already have vitals from the live stream, apply them - if (characterVitals[name]) { - updateCharacterVitals(name, characterVitals[name]); - } -} - -function updateCharacterWindow(name, data) { - const esc = CSS.escape(name); - const fmt = n => n != null ? n.toLocaleString() : '\u2014'; - - // -- Header -- - const header = document.getElementById(`charHeader-${esc}`); - if (header) { - const level = data.level || '?'; - const race = data.race || ''; - const gender = data.gender || ''; - const parts = [gender, race].filter(Boolean).join(' '); - header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...'; - const levelSpan = header.querySelector('.ts-level'); - if (levelSpan) levelSpan.textContent = level; - } - - // -- XP / Luminance row -- - const xplum = document.getElementById(`charXpLum-${esc}`); - if (xplum) { - const divs = xplum.querySelectorAll('div'); - if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`; - if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`; - if (divs[2]) { - const lum = data.luminance_earned != null && data.luminance_total != null - ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}` - : '\u2014'; - divs[2].textContent = `Luminance: ${lum}`; - } - if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`; - } - - // -- Attributes table -- - const attribTable = document.getElementById(`charAttribTable-${esc}`); - if (attribTable && data.attributes) { - const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self']; - const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)'); - order.forEach((attr, i) => { - if (rows[i] && data.attributes[attr]) { - const cells = rows[i].querySelectorAll('td'); - if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014'; - if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014'; - } - }); - } - - // -- Vitals table (base values) -- - const vitalsTable = document.getElementById(`charVitalsTable-${esc}`); - if (vitalsTable && data.vitals) { - const vOrder = ['health', 'stamina', 'mana']; - const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)'); - vOrder.forEach((v, i) => { - if (vRows[i] && data.vitals[v]) { - const cells = vRows[i].querySelectorAll('td'); - if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014'; - } - }); - } - - // -- Skill credits -- - const creditsTable = document.getElementById(`charCredits-${esc}`); - if (creditsTable) { - const cell = creditsTable.querySelector('td.ts-headerright'); - if (cell) cell.textContent = fmt(data.skill_credits); - } - - // -- Skills tab -- - const skillsBox = document.getElementById(`charSkills-${esc}`); - if (skillsBox && data.skills) { - const grouped = { Specialized: [], Trained: [] }; - for (const [skill, info] of Object.entries(data.skills)) { - const training = info.training || 'Untrained'; - if (training === 'Untrained' || training === 'Unusable') continue; - const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - if (grouped[training]) grouped[training].push({ name: displayName, base: info.base }); - } - for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name)); - - let html = ''; - html += ''; - if (grouped.Specialized.length) { - for (const s of grouped.Specialized) { - html += ``; - } - } - if (grouped.Trained.length) { - for (const s of grouped.Trained) { - html += ``; - } - } - html += '
SkillLevel
${s.name}${s.base}
${s.name}${s.base}
'; - skillsBox.innerHTML = html; - } - - // -- Titles tab -- - const titlesBox = document.getElementById(`charTitles-${esc}`); - if (titlesBox) { - const statsData = data.stats_data || data; - const titles = statsData.titles; - if (titles && titles.length > 0) { - let html = '
'; - for (const t of titles) html += `
${t}
`; - html += '
'; - titlesBox.innerHTML = html; - } else { - titlesBox.innerHTML = '
No titles data
'; - } - } - - // -- Properties-based tabs (Augmentations, Ratings, Other) -- - const statsData = data.stats_data || data; - const props = statsData.properties || {}; - - // Augmentations tab - const augsBox = document.getElementById(`charAugs-${esc}`); - if (augsBox) { - let augRows = [], auraRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val }); - if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val }); - } - if (augRows.length || auraRows.length) { - let html = ''; - if (augRows.length) { - html += '
Augmentations
'; - html += ''; - for (const a of augRows) html += ``; - html += '
NameUses
${a.name}${a.uses}
'; - } - if (auraRows.length) { - html += '
Auras
'; - html += ''; - for (const a of auraRows) html += ``; - html += '
NameUses
${a.name}${a.uses}
'; - } - augsBox.innerHTML = html; - } else { - augsBox.innerHTML = '
No augmentation data
'; - } - } - - // Ratings tab - const ratingsBox = document.getElementById(`charRatings-${esc}`); - if (ratingsBox) { - let rows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val }); - } - if (rows.length) { - let html = ''; - for (const r of rows) html += ``; - html += '
RatingValue
${r.name}${r.value}
'; - ratingsBox.innerHTML = html; - } else { - ratingsBox.innerHTML = '
No rating data
'; - } - } - - // Other tab (General, Masteries, Society) - const otherBox = document.getElementById(`charOther-${esc}`); - if (otherBox) { - let html = ''; - - // General section - let generalRows = []; - if (data.birth) generalRows.push({ name: 'Birth', value: data.birth }); - if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) }); - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val }); - } - if (generalRows.length) { - html += '
General
'; - html += ''; - for (const r of generalRows) html += ``; - html += '
${r.name}${r.value}
'; - } - - // Masteries section - let masteryRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_MASTERIES[nid]) { - const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`; - masteryRows.push({ name: TS_MASTERIES[nid], value: mName }); - } - } - if (masteryRows.length) { - html += '
Masteries
'; - html += ''; - for (const m of masteryRows) html += ``; - html += '
${m.name}${m.value}
'; - } - - // Society section - let societyRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_SOCIETY[nid] && val > 0) { - societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val }); - } - } - if (societyRows.length) { - html += '
Society
'; - html += ''; - for (const s of societyRows) html += ``; - html += '
${s.name}${s.rank} (${s.value})
'; - } - - otherBox.innerHTML = html || '
No additional data
'; - } - - // -- Allegiance section -- - const allegDiv = document.getElementById(`charAllegiance-${esc}`); - if (allegDiv && data.allegiance) { - const a = data.allegiance; - let html = '
Allegiance
'; - html += ''; - if (a.name) html += ``; - if (a.monarch) html += ``; - if (a.patron) html += ``; - if (a.rank !== undefined) html += ``; - if (a.followers !== undefined) html += ``; - html += '
Name${a.name}
Monarch${a.monarch.name || '\u2014'}
Patron${a.patron.name || '\u2014'}
Rank${a.rank}
Followers${a.followers}
'; - allegDiv.innerHTML = html; - } -} - -function updateCharacterVitals(name, vitals) { - const esc = CSS.escape(name); - const vitalsDiv = document.getElementById(`charVitals-${esc}`); - if (!vitalsDiv) return; - - const vitalElements = vitalsDiv.querySelectorAll('.ts-vital'); - - if (vitalElements[0]) { - const fill = vitalElements[0].querySelector('.ts-vital-fill'); - const txt = vitalElements[0].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.health_percentage || 0}%`; - if (txt && vitals.health_current !== undefined) { - txt.textContent = `${vitals.health_current} / ${vitals.health_max}`; - } - } - if (vitalElements[1]) { - const fill = vitalElements[1].querySelector('.ts-vital-fill'); - const txt = vitalElements[1].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`; - if (txt && vitals.stamina_current !== undefined) { - txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`; - } - } - if (vitalElements[2]) { - const fill = vitalElements[2].querySelector('.ts-vital-fill'); - const txt = vitalElements[2].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`; - if (txt && vitals.mana_current !== undefined) { - txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`; - } - } -} - // Inventory tooltip functions let inventoryTooltip = null; @@ -2335,11 +1841,6 @@ function initWebSocket() { updateVitalsDisplay(msg); } else if (msg.type === 'rare') { triggerEpicRareNotification(msg.character_name, msg.name); - } else if (msg.type === 'character_stats') { - characterStats[msg.character_name] = msg; - updateCharacterWindow(msg.character_name, msg); - } else if (msg.type === 'inventory_delta') { - updateInventoryLive(msg); } else if (msg.type === 'server_status') { handleServerStatusUpdate(msg); } @@ -2496,8 +1997,6 @@ wrap.addEventListener('mouseleave', () => { /* ---------- vitals display functions ----------------------------- */ // Store vitals data per character const characterVitals = {}; -const characterStats = {}; -const characterWindows = {}; function updateVitalsDisplay(vitalsMsg) { // Store the vitals data for this character @@ -2505,20 +2004,11 @@ function updateVitalsDisplay(vitalsMsg) { health_percentage: vitalsMsg.health_percentage, stamina_percentage: vitalsMsg.stamina_percentage, mana_percentage: vitalsMsg.mana_percentage, - health_current: vitalsMsg.health_current, - health_max: vitalsMsg.health_max, - stamina_current: vitalsMsg.stamina_current, - stamina_max: vitalsMsg.stamina_max, - mana_current: vitalsMsg.mana_current, - mana_max: vitalsMsg.mana_max, vitae: vitalsMsg.vitae }; - + // Re-render the player list to update vitals in the UI renderList(); - - // Also update character window if open - updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]); } function createVitalsHTML(characterName) { diff --git a/static/style.css b/static/style.css index d958bc8b..b17b8d70 100644 --- a/static/style.css +++ b/static/style.css @@ -525,7 +525,7 @@ body { margin-top: 4px; } -.chat-window, .stats-window, .inventory-window, .character-window { +.chat-window, .stats-window, .inventory-window { position: absolute; top: 10px; /* position window to start just right of the sidebar */ @@ -1590,261 +1590,3 @@ body.noselect, body.noselect * { to { opacity: 1; transform: translateY(0); } } -/* ============================================ - Character Window - AC Game UI Replica - ============================================ */ -/* === TreeStats-themed Character Window === */ -.character-window { - width: 740px !important; - height: auto !important; - min-height: 300px; - max-height: 90vh; -} -.character-window .window-content { - background-color: #000022; - color: #fff; - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - overflow-y: auto; - padding: 10px 15px 15px; -} - -/* -- Character header (name, level, title, server, XP/Lum) -- */ -.ts-character-header { - margin-bottom: 10px; -} -.ts-character-header h1 { - margin: 0 0 2px; - font-size: 28px; - color: #fff; - font-weight: bold; -} -.ts-character-header h1 span.ts-level { - font-size: 200%; - color: #fff27f; - float: right; -} -.ts-character-header .ts-subtitle { - font-size: 85%; - color: gold; -} -.ts-xplum { - font-size: 85%; - margin: 6px 0 10px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0 20px; -} -.ts-xplum .ts-left { text-align: left; } -.ts-xplum .ts-right { text-align: right; } - -/* -- Tab containers (two side-by-side) -- */ -.ts-tabrow { - display: flex; - gap: 20px; - flex-wrap: wrap; -} -.ts-tabcontainer { - width: 320px; - margin-bottom: 15px; -} -.ts-tabbar { - height: 30px; - display: flex; -} -.ts-tab { - float: left; - display: block; - padding: 5px 5px; - height: 18px; - font-size: 12px; - font-weight: bold; - color: #fff; - text-align: center; - cursor: pointer; - user-select: none; -} -.ts-tab.active { - border-top: 2px solid #af7a30; - border-right: 2px solid #af7a30; - border-left: 2px solid #af7a30; - border-bottom: none; - background-color: rgba(0, 100, 0, 0.4); -} -.ts-tab.inactive { - border-top: 2px solid #000022; - border-right: 2px solid #000022; - border-left: 2px solid #000022; - border-bottom: none; -} -.ts-box { - background-color: black; - color: #fff; - border: 2px solid #af7a30; - max-height: 400px; - overflow-x: hidden; - overflow-y: auto; -} -.ts-box.active { display: block; } -.ts-box.inactive { display: none; } - -/* -- Tables inside boxes -- */ -table.ts-char { - width: 100%; - font-size: 13px; - border-collapse: collapse; - border-spacing: 0; -} -table.ts-char td { - padding: 2px 6px; - white-space: nowrap; -} -table.ts-char tr.ts-colnames td { - background-color: #222; - font-weight: bold; - font-size: 12px; -} - -/* Attribute cells */ -table.ts-char td.ts-headerleft { - background-color: rgba(0, 100, 0, 0.4); -} -table.ts-char td.ts-headerright { - background-color: rgba(0, 0, 100, 0.4); -} -table.ts-char td.ts-creation { - color: #ccc; -} - -/* Skill rows */ -table.ts-char td.ts-specialized { - background: linear-gradient(to right, #392067, #392067, black); -} -table.ts-char td.ts-trained { - background: linear-gradient(to right, #0f3c3e, #0f3c3e, black); -} - -/* Section headers inside boxes */ -.ts-box .ts-section-title { - background-color: #222; - padding: 4px 8px; - font-weight: bold; - font-size: 13px; - border-bottom: 1px solid #af7a30; -} - -/* Titles list */ -.ts-titles-list { - padding: 6px 10px; - font-size: 13px; -} -.ts-titles-list div { - padding: 1px 0; -} - -/* Properties (augmentations, ratings, other) */ -table.ts-props { - width: 100%; - font-size: 13px; - border-collapse: collapse; -} -table.ts-props td { - padding: 2px 6px; -} -table.ts-props tr.ts-colnames td { - background-color: #222; - font-weight: bold; -} - -/* -- Live vitals bars (inside Attributes tab) -- */ -.ts-vitals { - padding: 6px 8px; - display: flex; - flex-direction: column; - gap: 4px; - border-bottom: 2px solid #af7a30; -} -.ts-vital { - display: flex; - align-items: center; - gap: 6px; -} -.ts-vital-label { - width: 55px; - font-size: 12px; - color: #ccc; -} -.ts-vital-bar { - flex: 1; - height: 14px; - overflow: hidden; - position: relative; - border: 1px solid #af7a30; -} -.ts-vital-fill { - height: 100%; - transition: width 0.5s ease; -} -.ts-health-bar .ts-vital-fill { background: #cc3333; width: 0%; } -.ts-stamina-bar .ts-vital-fill { background: #ccaa33; width: 0%; } -.ts-mana-bar .ts-vital-fill { background: #3366cc; width: 0%; } -.ts-vital-text { - width: 80px; - text-align: right; - font-size: 12px; - color: #ccc; -} - -/* -- Allegiance section (below tabs) -- */ -.ts-allegiance-section { - margin-top: 5px; - border: 2px solid #af7a30; - background-color: black; - padding: 0; -} -.ts-allegiance-section .ts-section-title { - background-color: #222; - padding: 4px 8px; - font-weight: bold; - font-size: 13px; - border-bottom: 1px solid #af7a30; -} -table.ts-allegiance { - width: 100%; - font-size: 13px; - border-collapse: collapse; -} -table.ts-allegiance td { - padding: 2px 6px; -} -table.ts-allegiance td:first-child { - color: #ccc; - width: 100px; -} - -/* Awaiting data placeholder */ -.ts-placeholder { - color: #666; - font-style: italic; - padding: 10px; - text-align: center; -} - -/* Scrollbar styling for ts-box */ -.ts-box::-webkit-scrollbar { width: 8px; } -.ts-box::-webkit-scrollbar-track { background: #000; } -.ts-box::-webkit-scrollbar-thumb { background: #af7a30; } - -.char-btn { - background: #000022; - color: #af7a30; - border: 1px solid #af7a30; - padding: 2px 6px; - border-radius: 3px; - cursor: pointer; - font-size: 11px; -} -.char-btn:hover { - background: rgba(0, 100, 0, 0.4); - border-color: #af7a30; -} - diff --git a/static/suitbuilder.html b/static/suitbuilder.html index a860a272..4a3b5c75 100644 --- a/static/suitbuilder.html +++ b/static/suitbuilder.html @@ -181,10 +181,6 @@ -
- - -
@@ -215,23 +211,6 @@
- -
- - -
-
- - -
-
- - -
-
- - -
@@ -496,4 +475,4 @@ - + \ No newline at end of file diff --git a/static/suitbuilder.js b/static/suitbuilder.js index 2954443d..dafd34cf 100644 --- a/static/suitbuilder.js +++ b/static/suitbuilder.js @@ -31,13 +31,7 @@ const COMMON_CANTRIPS = [ 'Legendary Life Magic Aptitude', // Defense 'Legendary Magic Resistance', - 'Legendary Invulnerability', - // Combat Skills - 'Legendary Recklessness Prowess', - 'Legendary Dual Wield Aptitude', - 'Legendary Deception Prowess', - 'Legendary Sneak Attack Prowess', - 'Legendary Dirty Fighting Prowess' + 'Legendary Invulnerability' ]; // Common legendary wards for lock form