1119 lines
39 KiB
Markdown
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
|