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