Redesign character window to match TreeStats layout and style

Replace the AC stone-themed single-scroll character window with a TreeStats-
style tabbed interface. Two side-by-side tab containers: left (Attributes,
Skills, Titles) and right (Augmentations, Ratings, Other), plus an Allegiance
section below. Exact TreeStats color palette (#000022 bg, #af7a30 gold
borders, purple specialized, teal trained). Backend accepts new properties
and titles fields in character_stats message for JSONB storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erik 2026-02-27 14:39:20 +00:00
parent 45cedd0ec9
commit 176fb020ec
3 changed files with 797 additions and 279 deletions

248
main.py
View file

@ -779,6 +779,7 @@ app = FastAPI()
# In-memory store mapping character_name to the most recent telemetry snapshot
live_snapshots: Dict[str, dict] = {}
live_vitals: Dict[str, dict] = {}
live_character_stats: Dict[str, dict] = {}
# Shared secret used to authenticate plugin WebSocket connections (override for production)
SHARED_SECRET = "your_shared_secret"
@ -875,6 +876,33 @@ class VitalsMessage(BaseModel):
vitae: int
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
properties: Optional[dict] = None # Dict[int, int] — DWORD properties (augs, ratings, etc.)
titles: Optional[list] = None # List[str] — character title names
@app.on_event("startup")
async def on_startup():
"""Event handler triggered when application starts up.
@ -1963,6 +1991,62 @@ async def ws_receive_snapshots(
except Exception as e:
logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# --- Character stats message: store character attributes/skills/progression and broadcast ---
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",
"properties", "titles"):
if stats_dict.get(key) is not None:
stats_data[key] = stats_dict[key]
# Upsert to database
await database.execute(
"""
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
""",
{
"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
# --- Quest message: update cache and broadcast (no database storage) ---
if msg_type == "quest":
character_name = data.get("character_name")
@ -2246,6 +2330,170 @@ async def get_stats(character_name: str):
logger.error(f"Failed to get stats for character {character_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# --- Character Stats API -------------------------------------------
@app.post("/character-stats/test")
async def test_character_stats_default():
"""Inject mock character_stats data for frontend development."""
return await test_character_stats("TestCharacter")
@app.post("/character-stats/test/{name}")
async def test_character_stats(name: str):
"""Inject mock character_stats data for a specific character name.
Processes through the same pipeline as real plugin data."""
mock_data = {
"type": "character_stats",
"timestamp": datetime.utcnow().isoformat() + "Z",
"character_name": name,
"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 as real data
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]
await database.execute(
"""
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
""",
{
"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))
@app.get("/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
row = await database.fetch_one(
"SELECT * FROM character_stats WHERE character_name = :name",
{"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")
# -------------------- static frontend ---------------------------
# Custom icon handler that prioritizes clean icons over originals
from fastapi.responses import FileResponse