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 = `
+
+
+
+
+
Attributes
+
+
+
Strength—
+
Quickness—
+
+
+
+
Coordination—
+
Self—
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 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