diff --git a/docs/plans/2026-02-26-character-stats-plan.md b/docs/plans/2026-02-26-character-stats-plan.md new file mode 100644 index 00000000..4f52b78d --- /dev/null +++ b/docs/plans/2026-02-26-character-stats-plan.md @@ -0,0 +1,1119 @@ +# Character Stats Window - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a live character stats window (AC game UI replica) to the Dereth Tracker map, with database persistence, WebSocket streaming, and an HTTP API. + +**Architecture:** New `character_stats` event type flows through the existing plugin WebSocket → backend handler → in-memory cache + DB persist → broadcast to browsers. Frontend opens a draggable window styled like the AC character panel. A test endpoint allows development without the plugin. + +**Tech Stack:** Python/FastAPI (backend), SQLAlchemy + PostgreSQL (DB), vanilla JavaScript (frontend), CSS (AC-themed styling) + +**CRITICAL:** Do NOT push to git until manual testing is complete. + +--- + +### Task 1: Add character_stats table to database + +**Files:** +- Modify: `db_async.py` (add table after line 169, the last table definition) +- Modify: `main.py` (add table creation to startup) + +**Step 1: Add table definition to db_async.py** + +After the `server_status` table definition (line 169), add: + +```python +character_stats = Table( + "character_stats", + metadata, + Column("character_name", String, primary_key=True, nullable=False), + Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("level", Integer, nullable=True), + Column("total_xp", BigInteger, nullable=True), + Column("unassigned_xp", BigInteger, nullable=True), + Column("luminance_earned", BigInteger, nullable=True), + Column("luminance_total", BigInteger, nullable=True), + Column("deaths", Integer, nullable=True), + Column("stats_data", JSON, nullable=False), +) +``` + +Make sure the necessary imports exist at the top of db_async.py: `BigInteger`, `JSON`, and `func` from sqlalchemy. Check which are already imported and add any missing ones. + +**Step 2: Add table creation to main.py startup** + +Find the `startup` event handler in main.py where tables are created (look for `CREATE TABLE IF NOT EXISTS` statements or `metadata.create_all`). Add the `character_stats` table creation alongside the existing tables. + +If tables are created via raw SQL, add: + +```sql +CREATE TABLE IF NOT EXISTS character_stats ( + character_name VARCHAR(255) PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + level INTEGER, + total_xp BIGINT, + unassigned_xp BIGINT, + luminance_earned BIGINT, + luminance_total BIGINT, + deaths INTEGER, + stats_data JSONB NOT NULL +); +``` + +**Step 3: Verify** + +Rebuild and restart the container: +```bash +docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker +``` + +Check logs for any startup errors: +```bash +docker logs mosswartoverlord-dereth-tracker-1 --tail 50 +``` + +**Step 4: Commit** + +```bash +git add db_async.py main.py +git commit -m "Add character_stats table for persistent character data storage" +``` + +--- + +### Task 2: Add Pydantic model and WebSocket handler for character_stats + +**Files:** +- Modify: `main.py` (Pydantic model near line 874, handler in /ws/position, in-memory cache near line 780) + +**Step 1: Add Pydantic model after VitalsMessage (line ~874)** + +```python +class CharacterStatsMessage(BaseModel): + """ + Model for the character_stats WebSocket message type. + Contains character attributes, skills, allegiance, and progression data. + Sent by plugin on login and every 10 minutes. + """ + character_name: str + timestamp: datetime + level: Optional[int] = None + total_xp: Optional[int] = None + unassigned_xp: Optional[int] = None + luminance_earned: Optional[int] = None + luminance_total: Optional[int] = None + deaths: Optional[int] = None + race: Optional[str] = None + gender: Optional[str] = None + birth: Optional[str] = None + current_title: Optional[int] = None + skill_credits: Optional[int] = None + attributes: Optional[dict] = None + vitals: Optional[dict] = None + skills: Optional[dict] = None + allegiance: Optional[dict] = None +``` + +Make sure `Optional` is imported from `typing` (check existing imports). + +**Step 2: Add in-memory cache near live_vitals (line ~780)** + +```python +live_character_stats: Dict[str, dict] = {} +``` + +**Step 3: Add handler in /ws/position WebSocket** + +Find the vitals handler block (line ~1954). After the vitals `continue` statement and before the quest handler, add: + +```python +if msg_type == "character_stats": + payload = data.copy() + payload.pop("type", None) + try: + stats_msg = CharacterStatsMessage.parse_obj(payload) + stats_dict = stats_msg.dict() + + # Cache in memory + live_character_stats[stats_msg.character_name] = stats_dict + + # Build stats_data JSONB (everything except extracted columns) + stats_data = {} + for key in ("attributes", "vitals", "skills", "allegiance", + "race", "gender", "birth", "current_title", "skill_credits"): + if stats_dict.get(key) is not None: + stats_data[key] = stats_dict[key] + + # Upsert to database + upsert_query = text(""" + INSERT INTO character_stats + (character_name, timestamp, level, total_xp, unassigned_xp, + luminance_earned, luminance_total, deaths, stats_data) + VALUES + (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, + :luminance_earned, :luminance_total, :deaths, :stats_data) + ON CONFLICT (character_name) DO UPDATE SET + timestamp = EXCLUDED.timestamp, + level = EXCLUDED.level, + total_xp = EXCLUDED.total_xp, + unassigned_xp = EXCLUDED.unassigned_xp, + luminance_earned = EXCLUDED.luminance_earned, + luminance_total = EXCLUDED.luminance_total, + deaths = EXCLUDED.deaths, + stats_data = EXCLUDED.stats_data + """) + await database.execute(upsert_query, { + "character_name": stats_msg.character_name, + "timestamp": stats_msg.timestamp, + "level": stats_msg.level, + "total_xp": stats_msg.total_xp, + "unassigned_xp": stats_msg.unassigned_xp, + "luminance_earned": stats_msg.luminance_earned, + "luminance_total": stats_msg.luminance_total, + "deaths": stats_msg.deaths, + "stats_data": json.dumps(stats_data), + }) + + # Broadcast to browser clients + await _broadcast_to_browser_clients(data) + logger.info(f"Updated character stats for {stats_msg.character_name}: Level {stats_msg.level}") + except Exception as e: + logger.error(f"Failed to process character_stats for {data.get('character_name', 'unknown')}: {e}", exc_info=True) + continue +``` + +Make sure `text` is imported from `sqlalchemy` and `json` is imported. Check existing imports. + +**Step 4: Verify** + +Rebuild and restart: +```bash +docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker +``` + +Check for startup errors: +```bash +docker logs mosswartoverlord-dereth-tracker-1 --tail 50 +``` + +**Step 5: Commit** + +```bash +git add main.py +git commit -m "Add character_stats WebSocket handler with DB persistence and broadcast" +``` + +--- + +### Task 3: Add HTTP API endpoint and test endpoint + +**Files:** +- Modify: `main.py` (add endpoints near other GET endpoints, around line 1157) + +**Step 1: Add GET endpoint for character stats** + +Near the other HTTP endpoints (after `/live` endpoint around line 1165), add: + +```python +@app.get("/api/character-stats/{name}") +async def get_character_stats(name: str): + """Return latest character stats. Checks in-memory cache first, falls back to DB.""" + try: + # Try in-memory cache first + if name in live_character_stats: + return JSONResponse(content=jsonable_encoder(live_character_stats[name])) + + # Fall back to database + query = text("SELECT * FROM character_stats WHERE character_name = :name") + row = await database.fetch_one(query, {"name": name}) + if row: + result = dict(row._mapping) + # Parse stats_data back from JSONB + if isinstance(result.get("stats_data"), str): + result["stats_data"] = json.loads(result["stats_data"]) + # Merge stats_data fields into top level for frontend compatibility + stats_data = result.pop("stats_data", {}) + result.update(stats_data) + return JSONResponse(content=jsonable_encoder(result)) + + return JSONResponse(content={"error": "No stats available for this character"}, status_code=404) + except Exception as e: + logger.error(f"Failed to get character stats for {name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") +``` + +**Step 2: Add test endpoint for development** + +```python +@app.post("/api/character-stats/test") +async def test_character_stats(): + """Inject mock character_stats data for frontend development. + Processes through the same pipeline as real plugin data.""" + mock_data = { + "type": "character_stats", + "timestamp": datetime.utcnow().isoformat() + "Z", + "character_name": "TestCharacter", + "level": 275, + "race": "Aluvian", + "gender": "Male", + "birth": "2018-03-15 14:22:33", + "total_xp": 191226310247, + "unassigned_xp": 4500000, + "skill_credits": 2, + "luminance_earned": 500000, + "luminance_total": 1500000, + "deaths": 3175, + "current_title": 42, + "attributes": { + "strength": {"base": 290, "creation": 100}, + "endurance": {"base": 200, "creation": 100}, + "coordination": {"base": 240, "creation": 100}, + "quickness": {"base": 220, "creation": 10}, + "focus": {"base": 250, "creation": 100}, + "self": {"base": 200, "creation": 100} + }, + "vitals": { + "health": {"base": 341}, + "stamina": {"base": 400}, + "mana": {"base": 300} + }, + "skills": { + "war_magic": {"base": 533, "training": "Specialized"}, + "life_magic": {"base": 440, "training": "Specialized"}, + "creature_enchantment": {"base": 430, "training": "Specialized"}, + "item_enchantment": {"base": 420, "training": "Specialized"}, + "void_magic": {"base": 510, "training": "Specialized"}, + "melee_defense": {"base": 488, "training": "Specialized"}, + "missile_defense": {"base": 470, "training": "Specialized"}, + "magic_defense": {"base": 460, "training": "Specialized"}, + "two_handed_combat": {"base": 420, "training": "Specialized"}, + "heavy_weapons": {"base": 410, "training": "Specialized"}, + "finesse_weapons": {"base": 400, "training": "Trained"}, + "light_weapons": {"base": 390, "training": "Trained"}, + "missile_weapons": {"base": 380, "training": "Trained"}, + "shield": {"base": 350, "training": "Trained"}, + "dual_wield": {"base": 340, "training": "Trained"}, + "arcane_lore": {"base": 330, "training": "Trained"}, + "mana_conversion": {"base": 320, "training": "Trained"}, + "healing": {"base": 300, "training": "Trained"}, + "lockpick": {"base": 280, "training": "Trained"}, + "assess_creature": {"base": 10, "training": "Untrained"}, + "assess_person": {"base": 10, "training": "Untrained"}, + "deception": {"base": 10, "training": "Untrained"}, + "leadership": {"base": 10, "training": "Untrained"}, + "loyalty": {"base": 10, "training": "Untrained"}, + "jump": {"base": 10, "training": "Untrained"}, + "run": {"base": 10, "training": "Untrained"}, + "salvaging": {"base": 10, "training": "Untrained"}, + "cooking": {"base": 10, "training": "Untrained"}, + "fletching": {"base": 10, "training": "Untrained"}, + "alchemy": {"base": 10, "training": "Untrained"}, + "sneak_attack": {"base": 10, "training": "Untrained"}, + "dirty_fighting": {"base": 10, "training": "Untrained"}, + "recklessness": {"base": 10, "training": "Untrained"}, + "summoning": {"base": 10, "training": "Untrained"} + }, + "allegiance": { + "name": "Knights of Dereth", + "monarch": {"name": "HighKing", "race": 1, "rank": 0, "gender": 0}, + "patron": {"name": "SirLancelot", "race": 1, "rank": 5, "gender": 0}, + "rank": 8, + "followers": 12 + } + } + + # Process through the same pipeline + payload = mock_data.copy() + payload.pop("type", None) + try: + stats_msg = CharacterStatsMessage.parse_obj(payload) + stats_dict = stats_msg.dict() + live_character_stats[stats_msg.character_name] = stats_dict + + stats_data = {} + for key in ("attributes", "vitals", "skills", "allegiance", + "race", "gender", "birth", "current_title", "skill_credits"): + if stats_dict.get(key) is not None: + stats_data[key] = stats_dict[key] + + upsert_query = text(""" + INSERT INTO character_stats + (character_name, timestamp, level, total_xp, unassigned_xp, + luminance_earned, luminance_total, deaths, stats_data) + VALUES + (:character_name, :timestamp, :level, :total_xp, :unassigned_xp, + :luminance_earned, :luminance_total, :deaths, :stats_data) + ON CONFLICT (character_name) DO UPDATE SET + timestamp = EXCLUDED.timestamp, + level = EXCLUDED.level, + total_xp = EXCLUDED.total_xp, + unassigned_xp = EXCLUDED.unassigned_xp, + luminance_earned = EXCLUDED.luminance_earned, + luminance_total = EXCLUDED.luminance_total, + deaths = EXCLUDED.deaths, + stats_data = EXCLUDED.stats_data + """) + await database.execute(upsert_query, { + "character_name": stats_msg.character_name, + "timestamp": stats_msg.timestamp, + "level": stats_msg.level, + "total_xp": stats_msg.total_xp, + "unassigned_xp": stats_msg.unassigned_xp, + "luminance_earned": stats_msg.luminance_earned, + "luminance_total": stats_msg.luminance_total, + "deaths": stats_msg.deaths, + "stats_data": json.dumps(stats_data), + }) + + await _broadcast_to_browser_clients(mock_data) + return {"status": "ok", "character_name": stats_msg.character_name} + except Exception as e: + logger.error(f"Test endpoint failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) +``` + +**IMPORTANT:** The test endpoint route `/api/character-stats/test` must be registered BEFORE the parameterized route `/api/character-stats/{name}`, otherwise FastAPI will treat "test" as a character name. Place the POST endpoint first. + +**Step 3: Verify** + +Rebuild and restart: +```bash +docker compose build --no-cache dereth-tracker && docker compose up -d dereth-tracker +``` + +Test the endpoints: +```bash +# Inject mock data +curl -X POST http://localhost:8000/api/character-stats/test + +# Retrieve it +curl http://localhost:8000/api/character-stats/TestCharacter +``` + +Both should return valid JSON with character data. + +**Step 4: Commit** + +```bash +git add main.py +git commit -m "Add character stats HTTP API and test endpoint for development" +``` + +--- + +### Task 4: Add "Char" button to player list + +**Files:** +- Modify: `static/script.js` (createNewListItem function, lines ~145-164) + +**Step 1: Add character button after inventory button** + +In `createNewListItem()`, find where the inventory button is created (lines ~145-160) and appended (line ~164). After the inventory button creation and before the `buttonsContainer.appendChild` calls, add: + +```javascript +const charBtn = document.createElement('button'); +charBtn.className = 'char-btn'; +charBtn.textContent = 'Char'; +charBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; + if (playerData) { + showCharacterWindow(playerData.character_name); + } +}); +``` + +Then add `buttonsContainer.appendChild(charBtn);` alongside the other button appends. + +Also store the reference on the list item: `li.charBtn = charBtn;` (same pattern as other buttons). + +**Step 2: Add stub showCharacterWindow function** + +Add a placeholder function (we'll flesh it out in Task 5): + +```javascript +function showCharacterWindow(name) { + debugLog('showCharacterWindow called for:', name); + const windowId = `characterWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Character: ${name}`, 'character-window', + { width: '450px', height: '650px' } + ); + + if (!isNew) { + debugLog('Existing character window found, showing it'); + return; + } + + content.innerHTML = '
Awaiting character data...
'; +} +``` + +Place this near the other show*Window functions (after showInventoryWindow). + +**Step 3: Verify** + +Rebuild container and load the map page. Each player in the list should now have a "Char" button. Clicking it should open a draggable window with "Awaiting character data...". + +**Step 4: Commit** + +```bash +git add static/script.js +git commit -m "Add Char button to player list with stub character window" +``` + +--- + +### Task 5: Build the character window content and AC-themed layout + +**Files:** +- Modify: `static/script.js` (replace stub showCharacterWindow) + +**Step 1: Replace showCharacterWindow with full implementation** + +Replace the stub from Task 4 with the complete function. This is the largest piece of frontend code. + +```javascript +/* ---------- Character stats state -------------------------------- */ +const characterStats = {}; +const characterWindows = {}; + +function showCharacterWindow(name) { + debugLog('showCharacterWindow called for:', name); + const windowId = `characterWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Character: ${name}`, 'character-window' + ); + + if (!isNew) { + debugLog('Existing character window found, showing it'); + return; + } + + win.dataset.character = name; + characterWindows[name] = win; + + // Build the AC-style character panel + content.innerHTML = ` +
+
+
${name}
+
Awaiting character data...
+
+ +
+
Attributes
+
+
+
Strength
+
Quickness
+
+
+
Endurance
+
Focus
+
+
+
Coordination
+
Self
+
+
+
+ +
+
Vitals
+
+
+ Health +
+ — / — +
+
+ Stamina +
+ — / — +
+
+ Mana +
+ — / — +
+
+
+ +
+
Skills
+
+
Awaiting data...
+
+
+ +
+
Allegiance
+
+
Awaiting data...
+
+
+ + +
+ `; + + // Fetch existing data from API + fetch(`${API_BASE}/api/character-stats/${encodeURIComponent(name)}`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data && !data.error) { + characterStats[name] = data; + updateCharacterWindow(name, data); + } + }) + .catch(err => handleError('Character stats', err)); + + // If we already have vitals from the live stream, apply them + if (characterVitals[name]) { + updateCharacterVitals(name, characterVitals[name]); + } +} + +function updateCharacterWindow(name, data) { + const escapedName = CSS.escape(name); + + // Header + const header = document.getElementById(`charHeader-${escapedName}`); + if (header) { + const level = data.level || '?'; + const race = data.race || ''; + const gender = data.gender || ''; + const subtitle = [ + `Level ${level}`, + race, + gender + ].filter(Boolean).join(' · '); + header.querySelector('.ac-subtitle').textContent = subtitle; + } + + // Attributes + const attribs = document.getElementById(`charAttribs-${escapedName}`); + if (attribs && data.attributes) { + const order = [ + ['strength', 'quickness'], + ['endurance', 'focus'], + ['coordination', 'self'] + ]; + const rows = attribs.querySelectorAll('.ac-attr-row'); + order.forEach((pair, i) => { + if (rows[i]) { + const cells = rows[i].querySelectorAll('.ac-attr-value'); + pair.forEach((attr, j) => { + if (cells[j] && data.attributes[attr]) { + const val = data.attributes[attr].base || '—'; + const creation = data.attributes[attr].creation; + cells[j].textContent = val; + if (creation !== undefined) { + cells[j].title = `Creation: ${creation}`; + } + } + }); + } + }); + } + + // Skills + const skillsDiv = document.getElementById(`charSkills-${escapedName}`); + if (skillsDiv && data.skills) { + const grouped = { Specialized: [], Trained: [], Untrained: [] }; + for (const [skill, info] of Object.entries(data.skills)) { + const training = info.training || 'Untrained'; + const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + if (grouped[training]) { + grouped[training].push({ name: displayName, base: info.base }); + } + } + // Sort each group by base value descending + for (const group of Object.values(grouped)) { + group.sort((a, b) => b.base - a.base); + } + + let html = ''; + for (const [training, skills] of Object.entries(grouped)) { + if (skills.length === 0) continue; + html += `
`; + html += `
${training}
`; + for (const s of skills) { + html += `
`; + html += `${s.name}`; + html += `${s.base}`; + html += `
`; + } + html += `
`; + } + skillsDiv.innerHTML = html; + } + + // Allegiance + const allegianceDiv = document.getElementById(`charAllegiance-${escapedName}`); + if (allegianceDiv && data.allegiance) { + const a = data.allegiance; + let html = ''; + if (a.name) html += `
Allegiance:${a.name}
`; + if (a.monarch) html += `
Monarch:${a.monarch.name || '—'}
`; + if (a.patron) html += `
Patron:${a.patron.name || '—'}
`; + if (a.rank !== undefined) html += `
Rank:${a.rank}
`; + if (a.followers !== undefined) html += `
Followers:${a.followers}
`; + allegianceDiv.innerHTML = html || '
No allegiance
'; + } + + // Footer + const footer = document.getElementById(`charFooter-${escapedName}`); + if (footer) { + const rows = footer.querySelectorAll('.ac-footer-row'); + const formatNum = n => n != null ? n.toLocaleString() : '—'; + if (rows[0]) rows[0].querySelector('span:last-child').textContent = formatNum(data.total_xp); + if (rows[1]) rows[1].querySelector('span:last-child').textContent = formatNum(data.unassigned_xp); + if (rows[2]) { + const lum = data.luminance_earned != null && data.luminance_total != null + ? `${formatNum(data.luminance_earned)} / ${formatNum(data.luminance_total)}` + : '—'; + rows[2].querySelector('span:last-child').textContent = lum; + } + if (rows[3]) rows[3].querySelector('span:last-child').textContent = formatNum(data.deaths); + } +} + +function updateCharacterVitals(name, vitals) { + const escapedName = CSS.escape(name); + const vitalsDiv = document.getElementById(`charVitals-${escapedName}`); + if (!vitalsDiv) return; + + const vitalElements = vitalsDiv.querySelectorAll('.ac-vital'); + + // Health + if (vitalElements[0]) { + const fill = vitalElements[0].querySelector('.ac-vital-fill'); + const text = vitalElements[0].querySelector('.ac-vital-text'); + if (fill) fill.style.width = `${vitals.health_percentage || 0}%`; + if (text && vitals.health_current !== undefined) { + text.textContent = `${vitals.health_current} / ${vitals.health_max}`; + } + } + // Stamina + if (vitalElements[1]) { + const fill = vitalElements[1].querySelector('.ac-vital-fill'); + const text = vitalElements[1].querySelector('.ac-vital-text'); + if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`; + if (text && vitals.stamina_current !== undefined) { + text.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`; + } + } + // Mana + if (vitalElements[2]) { + const fill = vitalElements[2].querySelector('.ac-vital-fill'); + const text = vitalElements[2].querySelector('.ac-vital-text'); + if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`; + if (text && vitals.mana_current !== undefined) { + text.textContent = `${vitals.mana_current} / ${vitals.mana_max}`; + } + } +} +``` + +**Step 2: Hook into existing WebSocket message handler** + +Find the WebSocket message handler (line ~1834-1850) where `msg.type` is dispatched. Add a case for `character_stats`: + +```javascript +} else if (msg.type === 'character_stats') { + characterStats[msg.character_name] = msg; + updateCharacterWindow(msg.character_name, msg); +``` + +**Step 3: Update existing updateVitalsDisplay to also update character windows** + +Find `updateVitalsDisplay` (line ~1999). At the end of the function, add: + +```javascript +// Also update character window if open +updateCharacterVitals(vitalsMsg.character_name, vitalsMsg); +``` + +**Step 4: Verify** + +Rebuild container. Open map, click "Char" on a player. Window opens with "Awaiting" placeholders. In another terminal: +```bash +curl -X POST http://localhost:8000/api/character-stats/test +``` + +If TestCharacter is in the player list, their character window should populate. If not, verify the GET endpoint returns data: +```bash +curl http://localhost:8000/api/character-stats/TestCharacter +``` + +**Step 5: Commit** + +```bash +git add static/script.js +git commit -m "Add full character window with live stats, vitals, skills, and allegiance display" +``` + +--- + +### Task 6: Add AC-themed CSS for character window + +**Files:** +- Modify: `static/style.css` + +**Step 1: Add character window to base window styles** + +Find the base window style selector (line ~528): +```css +.chat-window, .stats-window, .inventory-window { +``` + +Add `.character-window` to the selector: +```css +.chat-window, .stats-window, .inventory-window, .character-window { +``` + +**Step 2: Add AC-themed character window styles** + +At the end of `static/style.css`, add the full AC theme: + +```css +/* ============================================ + Character Window - AC Game UI Replica + ============================================ */ +.character-window { + width: 450px !important; + height: 650px !important; +} + +.ac-panel { + display: flex; + flex-direction: column; + height: 100%; + background: linear-gradient(135deg, #1a1410 0%, #2a2218 50%, #1a1410 100%); + color: #c8b89a; + font-size: 13px; + overflow-y: auto; +} + +/* Header */ +.ac-header { + padding: 12px 15px; + border-bottom: 1px solid #8b7355; + text-align: center; +} +.ac-name { + font-size: 18px; + font-weight: bold; + color: #d4a843; + letter-spacing: 1px; +} +.ac-subtitle { + font-size: 12px; + color: #7a6e5e; + margin-top: 4px; +} + +/* Sections */ +.ac-section { + padding: 8px 15px; + border-bottom: 1px solid #3a3228; +} +.ac-section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 2px; + color: #8b7355; + margin-bottom: 6px; + padding-bottom: 3px; + border-bottom: 1px solid #3a3228; +} + +/* Attributes */ +.ac-attributes { + display: flex; + flex-direction: column; + gap: 2px; +} +.ac-attr-row { + display: flex; + gap: 10px; +} +.ac-attr { + flex: 1; + display: flex; + justify-content: space-between; + padding: 3px 8px; + background: rgba(42, 34, 24, 0.6); + border: 1px solid #3a3228; + border-radius: 2px; +} +.ac-attr-label { + color: #7a6e5e; +} +.ac-attr-value { + color: #d4a843; + font-weight: bold; +} + +/* Vitals */ +.ac-vitals { + display: flex; + flex-direction: column; + gap: 6px; +} +.ac-vital { + display: flex; + align-items: center; + gap: 8px; +} +.ac-vital-label { + width: 55px; + font-size: 12px; + color: #7a6e5e; +} +.ac-vital-bar { + flex: 1; + height: 16px; + border-radius: 2px; + overflow: hidden; + position: relative; +} +.ac-vital-fill { + height: 100%; + transition: width 0.5s ease; + border-radius: 2px; +} +.ac-health-bar { background: #4a1a1a; } +.ac-health-bar .ac-vital-fill { background: #cc3333; width: 0%; } +.ac-stamina-bar { background: #4a3a1a; } +.ac-stamina-bar .ac-vital-fill { background: #ccaa33; width: 0%; } +.ac-mana-bar { background: #1a2a4a; } +.ac-mana-bar .ac-vital-fill { background: #3366cc; width: 0%; } +.ac-vital-text { + width: 80px; + text-align: right; + font-size: 12px; + color: #c8b89a; +} + +/* Skills */ +.ac-skills-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.ac-skills { + overflow-y: auto; + max-height: 200px; + flex: 1; +} +.ac-skill-group { + margin-bottom: 4px; +} +.ac-skill-group-title { + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; + padding: 3px 8px; + margin-bottom: 1px; +} +.ac-skill-group-title.ac-specialized { color: #d4a843; } +.ac-skill-group-title.ac-trained { color: #c8b89a; } +.ac-skill-group-title.ac-untrained { color: #5a5248; } + +.ac-skill-row { + display: flex; + justify-content: space-between; + padding: 2px 12px; + border-bottom: 1px solid rgba(58, 50, 40, 0.4); +} +.ac-skill-row:hover { + background: rgba(139, 115, 85, 0.1); +} +.ac-skill-row.ac-specialized .ac-skill-name { color: #d4a843; } +.ac-skill-row.ac-specialized .ac-skill-value { color: #d4a843; font-weight: bold; } +.ac-skill-row.ac-trained .ac-skill-name { color: #c8b89a; } +.ac-skill-row.ac-trained .ac-skill-value { color: #c8b89a; } +.ac-skill-row.ac-untrained .ac-skill-name { color: #5a5248; } +.ac-skill-row.ac-untrained .ac-skill-value { color: #5a5248; } + +.ac-skill-name { font-size: 12px; } +.ac-skill-value { font-size: 12px; font-family: monospace; } +.ac-skill-placeholder { color: #5a5248; font-style: italic; padding: 8px; } + +/* Allegiance */ +.ac-allegiance { + display: flex; + flex-direction: column; + gap: 2px; +} +.ac-alleg-row { + display: flex; + justify-content: space-between; + padding: 2px 8px; +} +.ac-alleg-row span:first-child { color: #7a6e5e; } +.ac-alleg-row span:last-child { color: #c8b89a; } + +/* Footer */ +.ac-footer { + padding: 8px 15px; + border-top: 1px solid #8b7355; + background: rgba(26, 20, 16, 0.8); +} +.ac-footer-row { + display: flex; + justify-content: space-between; + padding: 2px 0; + font-size: 12px; +} +.ac-footer-row span:first-child { color: #7a6e5e; } +.ac-footer-row span:last-child { color: #d4a843; } + +/* Char button in player list */ +.char-btn { + background: #2a2218; + color: #d4a843; + border: 1px solid #8b7355; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; +} +.char-btn:hover { + background: #3a3228; + border-color: #d4a843; +} +``` + +**Step 3: Verify** + +Rebuild container. Open map, click "Char" on a player. Window should have the dark AC theme with gold accents. Inject test data: +```bash +curl -X POST http://localhost:8000/api/character-stats/test +``` + +Check that: +- Attributes show in a 3x2 grid with gold values +- Vitals have colored bars (red/yellow/blue) +- Skills are grouped by Specialized (gold), Trained (light), Untrained (grey) +- Footer shows XP, luminance, deaths in gold text +- Window is scrollable if content overflows + +**Step 4: Commit** + +```bash +git add static/style.css +git commit -m "Add AC game UI replica styling for character stats window" +``` + +--- + +### Task 7: Wire up live vitals to character window and final polish + +**Files:** +- Modify: `static/script.js` (minor wiring) + +**Step 1: Handle test endpoint data for any online character** + +The test endpoint sends data for "TestCharacter" which may not be online. To test with a real player, update the test endpoint to accept a character name parameter. Modify the test endpoint in `main.py`: + +Change the test endpoint signature to: +```python +@app.post("/api/character-stats/test/{name}") +async def test_character_stats(name: str): +``` + +And replace `"character_name": "TestCharacter"` with `"character_name": name` in the mock_data. + +Also keep the original parameterless version that defaults to "TestCharacter": +```python +@app.post("/api/character-stats/test") +async def test_character_stats_default(): + return await test_character_stats("TestCharacter") +``` + +**IMPORTANT:** Both test routes must be registered BEFORE the `GET /api/character-stats/{name}` route. + +**Step 2: Verify end-to-end with a live player** + +Rebuild container. Open the map, find an online player (e.g. "Barris"). In another terminal: +```bash +curl -X POST http://localhost:8000/api/character-stats/test/Barris +``` + +Then click "Char" on Barris in the player list. The window should: +1. Open with AC theme +2. Show all mock attributes, skills, allegiance +3. Show live vitals bars updating every 5 seconds (from existing vitals stream) + +**Step 3: Commit** + +```bash +git add main.py static/script.js +git commit -m "Add parameterized test endpoint and final character window wiring" +``` + +--- + +### Task 8: Full manual testing pass + +**CRITICAL: Do not push to git until ALL tests pass.** + +**Test Checklist:** + +| # | Test | How to Verify | +|---|------|---------------| +| 1 | Container starts | No errors in `docker logs` | +| 2 | Char button appears | Each player in list has "Char" button alongside Chat/Stats/Inventory | +| 3 | Window opens | Click Char → draggable AC-themed window appears | +| 4 | Empty state | Window shows "Awaiting character data..." when no stats exist | +| 5 | Test endpoint | `curl -X POST .../api/character-stats/test` returns 200 OK | +| 6 | GET endpoint | `curl .../api/character-stats/TestCharacter` returns full stats JSON | +| 7 | Window populates | After test POST, open window shows attributes/skills/allegiance | +| 8 | Live broadcast | Window updates in real-time when test POST fires (no refresh needed) | +| 9 | Vitals bars | HP/Stam/Mana bars update every 5s from existing vitals stream | +| 10 | Skills grouped | Specialized (gold), Trained (white), Untrained (grey) in correct groups | +| 11 | Multiple windows | Open character windows for different players, all work independently | +| 12 | Window z-index | Latest opened/clicked window is on top | +| 13 | Close and reopen | Close window, click Char again → same window reappears | +| 14 | Other features | Chat, Stats, Inventory, pan/zoom, heatmap still work (no regression) | +| 15 | Console clean | No JavaScript errors in browser console | +| 16 | Named test | `curl -X POST .../api/character-stats/test/PlayerName` works for online player | + +**If all pass:** Ready to push. Inform user. + +**If any fail:** Fix, re-test, repeat. + +--- + +## Files Modified Summary + +| File | Changes | +|------|---------| +| `db_async.py` | New `character_stats` table definition | +| `main.py` | Pydantic model, in-memory cache, WebSocket handler, HTTP endpoints, test endpoint | +| `static/script.js` | Char button, showCharacterWindow, updateCharacterWindow, updateCharacterVitals, WebSocket handler | +| `static/style.css` | Full AC-themed character window styles | + +--- + +## Plugin Handoff Spec + +After all tasks pass testing, generate a prompt for the plugin developer describing: +1. The exact JSON contract (from design doc) +2. Send on login + every 10 minutes +3. Use existing WebSocket connection +4. Event type: `character_stats` +5. Data sources: CharacterFilter API for attributes/vitals, FileService.SkillTable for skills, ServerDispatch for allegiance