diff --git a/db_async.py b/db_async.py index 8a5623e7..998d6707 100644 --- a/db_async.py +++ b/db_async.py @@ -9,6 +9,7 @@ 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") @@ -175,6 +176,20 @@ 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. @@ -250,6 +265,26 @@ 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 new file mode 100644 index 00000000..0efd8967 --- /dev/null +++ b/docs/plans/2026-02-26-character-stats-design.md @@ -0,0 +1,308 @@ +# 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 new file mode 100644 index 00000000..4f52b78d --- /dev/null +++ b/docs/plans/2026-02-26-character-stats-plan.md @@ -0,0 +1,1119 @@ +# 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 new file mode 100644 index 00000000..62614bd6 --- /dev/null +++ b/docs/plans/2026-02-26-plugin-character-stats-design.md @@ -0,0 +1,201 @@ +# 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 new file mode 100644 index 00000000..1e95f065 --- /dev/null +++ b/docs/plans/2026-02-26-plugin-character-stats-plan.md @@ -0,0 +1,576 @@ +# 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 d1c0a502..0592f272 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -36,7 +36,11 @@ 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 564e31dc..400f5866 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -358,7 +358,19 @@ 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) @@ -1345,16 +1357,15 @@ async def process_inventory(inventory: InventoryItem): item_ids = await database.fetch_all(item_ids_query, {"character_name": inventory.character_name}) if item_ids: - id_list = [str(row['id']) for row in item_ids] - id_placeholder = ','.join(id_list) - + db_ids = [row['id'] for row in item_ids] + # Delete from all related tables first - 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})") + 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} + ) # Finally delete from main items table await database.execute( @@ -1401,25 +1412,29 @@ 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, @@ -1536,6 +1551,226 @@ 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.", @@ -3507,7 +3742,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) - print(f"DEBUG: Total items for query: {debug_result['total']}") + logger.debug(f"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 f71f4dd5..146e2a00 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,7 @@ from db_async import ( spawn_events, rare_events, character_inventories, + character_stats, portals, server_health_checks, server_status, @@ -778,6 +779,7 @@ 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" @@ -874,6 +876,33 @@ 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. @@ -1950,6 +1979,38 @@ 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() @@ -1962,6 +2023,62 @@ 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") @@ -2245,6 +2362,170 @@ 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 ecbf64ac..a8033863 100644 --- a/static/script.js +++ b/static/script.js @@ -159,9 +159,21 @@ 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 @@ -169,6 +181,7 @@ function createNewListItem() { li.chatBtn = chatBtn; li.statsBtn = statsBtn; li.inventoryBtn = inventoryBtn; + li.charBtn = charBtn; return li; } @@ -880,6 +893,175 @@ 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}`; @@ -924,139 +1106,7 @@ function showInventoryWindow(name) { // Render each item data.items.forEach(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); + grid.appendChild(createInventorySlot(item)); }); invContent.appendChild(grid); @@ -1075,6 +1125,450 @@ 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; @@ -1841,6 +2335,11 @@ 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); } @@ -1997,6 +2496,8 @@ 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 @@ -2004,11 +2505,20 @@ 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 b17b8d70..d958bc8b 100644 --- a/static/style.css +++ b/static/style.css @@ -525,7 +525,7 @@ body { margin-top: 4px; } -.chat-window, .stats-window, .inventory-window { +.chat-window, .stats-window, .inventory-window, .character-window { position: absolute; top: 10px; /* position window to start just right of the sidebar */ @@ -1590,3 +1590,261 @@ 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 4a3b5c75..a860a272 100644 --- a/static/suitbuilder.html +++ b/static/suitbuilder.html @@ -181,6 +181,10 @@ +
+ + +
@@ -211,6 +215,23 @@
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -475,4 +496,4 @@ - \ No newline at end of file + diff --git a/static/suitbuilder.js b/static/suitbuilder.js index dafd34cf..2954443d 100644 --- a/static/suitbuilder.js +++ b/static/suitbuilder.js @@ -31,7 +31,13 @@ const COMMON_CANTRIPS = [ 'Legendary Life Magic Aptitude', // Defense 'Legendary Magic Resistance', - 'Legendary Invulnerability' + 'Legendary Invulnerability', + // Combat Skills + 'Legendary Recklessness Prowess', + 'Legendary Dual Wield Aptitude', + 'Legendary Deception Prowess', + 'Legendary Sneak Attack Prowess', + 'Legendary Dirty Fighting Prowess' ]; // Common legendary wards for lock form