MosswartOverlord/docs/plans/2026-02-26-character-stats-plan.md
erik a824451365 Add character stats window implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:57:07 +00:00

39 KiB

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:

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:

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:

docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker

Check logs for any startup errors:

docker logs mosswartoverlord-dereth-tracker-1 --tail 50

Step 4: Commit

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)

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)

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:

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:

docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker

Check for startup errors:

docker logs mosswartoverlord-dereth-tracker-1 --tail 50

Step 5: Commit

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:

@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

@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:

docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker

Test the endpoints:

# 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

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:

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):

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 = '<div style="padding: 20px; color: #c8b89a;">Awaiting character data...</div>';
}

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

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.

/* ---------- 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 = `
        <div class="ac-panel">
            <div class="ac-header" id="charHeader-${CSS.escape(name)}">
                <div class="ac-name">${name}</div>
                <div class="ac-subtitle">Awaiting character data...</div>
            </div>

            <div class="ac-section">
                <div class="ac-section-title">Attributes</div>
                <div class="ac-attributes" id="charAttribs-${CSS.escape(name)}">
                    <div class="ac-attr-row">
                        <div class="ac-attr"><span class="ac-attr-label">Strength</span><span class="ac-attr-value">—</span></div>
                        <div class="ac-attr"><span class="ac-attr-label">Quickness</span><span class="ac-attr-value">—</span></div>
                    </div>
                    <div class="ac-attr-row">
                        <div class="ac-attr"><span class="ac-attr-label">Endurance</span><span class="ac-attr-value">—</span></div>
                        <div class="ac-attr"><span class="ac-attr-label">Focus</span><span class="ac-attr-value">—</span></div>
                    </div>
                    <div class="ac-attr-row">
                        <div class="ac-attr"><span class="ac-attr-label">Coordination</span><span class="ac-attr-value">—</span></div>
                        <div class="ac-attr"><span class="ac-attr-label">Self</span><span class="ac-attr-value">—</span></div>
                    </div>
                </div>
            </div>

            <div class="ac-section">
                <div class="ac-section-title">Vitals</div>
                <div class="ac-vitals" id="charVitals-${CSS.escape(name)}">
                    <div class="ac-vital">
                        <span class="ac-vital-label">Health</span>
                        <div class="ac-vital-bar ac-health-bar"><div class="ac-vital-fill"></div></div>
                        <span class="ac-vital-text">— / —</span>
                    </div>
                    <div class="ac-vital">
                        <span class="ac-vital-label">Stamina</span>
                        <div class="ac-vital-bar ac-stamina-bar"><div class="ac-vital-fill"></div></div>
                        <span class="ac-vital-text">— / —</span>
                    </div>
                    <div class="ac-vital">
                        <span class="ac-vital-label">Mana</span>
                        <div class="ac-vital-bar ac-mana-bar"><div class="ac-vital-fill"></div></div>
                        <span class="ac-vital-text">— / —</span>
                    </div>
                </div>
            </div>

            <div class="ac-section ac-skills-section">
                <div class="ac-section-title">Skills</div>
                <div class="ac-skills" id="charSkills-${CSS.escape(name)}">
                    <div class="ac-skill-placeholder">Awaiting data...</div>
                </div>
            </div>

            <div class="ac-section">
                <div class="ac-section-title">Allegiance</div>
                <div class="ac-allegiance" id="charAllegiance-${CSS.escape(name)}">
                    <div class="ac-skill-placeholder">Awaiting data...</div>
                </div>
            </div>

            <div class="ac-footer" id="charFooter-${CSS.escape(name)}">
                <div class="ac-footer-row"><span>Total XP:</span><span>—</span></div>
                <div class="ac-footer-row"><span>Unassigned XP:</span><span>—</span></div>
                <div class="ac-footer-row"><span>Luminance:</span><span>—</span></div>
                <div class="ac-footer-row"><span>Deaths:</span><span>—</span></div>
            </div>
        </div>
    `;

    // 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 += `<div class="ac-skill-group">`;
            html += `<div class="ac-skill-group-title ac-${training.toLowerCase()}">${training}</div>`;
            for (const s of skills) {
                html += `<div class="ac-skill-row ac-${training.toLowerCase()}">`;
                html += `<span class="ac-skill-name">${s.name}</span>`;
                html += `<span class="ac-skill-value">${s.base}</span>`;
                html += `</div>`;
            }
            html += `</div>`;
        }
        skillsDiv.innerHTML = html;
    }

    // Allegiance
    const allegianceDiv = document.getElementById(`charAllegiance-${escapedName}`);
    if (allegianceDiv && data.allegiance) {
        const a = data.allegiance;
        let html = '';
        if (a.name) html += `<div class="ac-alleg-row"><span>Allegiance:</span><span>${a.name}</span></div>`;
        if (a.monarch) html += `<div class="ac-alleg-row"><span>Monarch:</span><span>${a.monarch.name || '—'}</span></div>`;
        if (a.patron) html += `<div class="ac-alleg-row"><span>Patron:</span><span>${a.patron.name || '—'}</span></div>`;
        if (a.rank !== undefined) html += `<div class="ac-alleg-row"><span>Rank:</span><span>${a.rank}</span></div>`;
        if (a.followers !== undefined) html += `<div class="ac-alleg-row"><span>Followers:</span><span>${a.followers}</span></div>`;
        allegianceDiv.innerHTML = html || '<div class="ac-skill-placeholder">No allegiance</div>';
    }

    // 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:

} 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:

// 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:

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:

curl http://localhost:8000/api/character-stats/TestCharacter

Step 5: Commit

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):

.chat-window, .stats-window, .inventory-window {

Add .character-window to the selector:

.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:

/* ============================================
   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:

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

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:

@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":

@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:

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

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