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

1119 lines
39 KiB
Markdown

# 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 = '<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**
```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 = `
<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`:
```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