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