# 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