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:
parent
45cedd0ec9
commit
176fb020ec
3 changed files with 797 additions and 279 deletions
248
main.py
248
main.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue