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
|
# In-memory store mapping character_name to the most recent telemetry snapshot
|
||||||
live_snapshots: Dict[str, dict] = {}
|
live_snapshots: Dict[str, dict] = {}
|
||||||
live_vitals: 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 used to authenticate plugin WebSocket connections (override for production)
|
||||||
SHARED_SECRET = "your_shared_secret"
|
SHARED_SECRET = "your_shared_secret"
|
||||||
|
|
@ -875,6 +876,33 @@ class VitalsMessage(BaseModel):
|
||||||
vitae: int
|
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")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
"""Event handler triggered when application starts up.
|
"""Event handler triggered when application starts up.
|
||||||
|
|
@ -1963,6 +1991,62 @@ async def ws_receive_snapshots(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
|
logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
|
||||||
continue
|
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) ---
|
# --- Quest message: update cache and broadcast (no database storage) ---
|
||||||
if msg_type == "quest":
|
if msg_type == "quest":
|
||||||
character_name = data.get("character_name")
|
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)
|
logger.error(f"Failed to get stats for character {character_name}: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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 ---------------------------
|
# -------------------- static frontend ---------------------------
|
||||||
# Custom icon handler that prioritizes clean icons over originals
|
# Custom icon handler that prioritizes clean icons over originals
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
|
||||||
486
static/script.js
486
static/script.js
|
|
@ -1088,6 +1088,63 @@ function showInventoryWindow(name) {
|
||||||
debugLog('Inventory window created for:', name);
|
debugLog('Inventory window created for:', name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === TreeStats Property ID Mappings ===
|
||||||
|
const TS_AUGMENTATIONS = {
|
||||||
|
218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement",
|
||||||
|
221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will",
|
||||||
|
224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence",
|
||||||
|
227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule",
|
||||||
|
230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment",
|
||||||
|
233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune",
|
||||||
|
236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance",
|
||||||
|
239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner",
|
||||||
|
241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler",
|
||||||
|
244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless",
|
||||||
|
294: "Master of the Steel Circle", 295: "Master of the Focused Eye",
|
||||||
|
296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer",
|
||||||
|
298: "Iron Skin of the Invincible", 299: "Jack of All Trades",
|
||||||
|
300: "Infused Void Magic", 301: "Infused War Magic",
|
||||||
|
302: "Infused Life Magic", 309: "Infused Item Magic",
|
||||||
|
310: "Infused Creature Magic", 326: "Clutch of the Miser",
|
||||||
|
328: "Enduring Enchantment"
|
||||||
|
};
|
||||||
|
const TS_AURAS = {
|
||||||
|
333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution",
|
||||||
|
336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow",
|
||||||
|
340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization",
|
||||||
|
365: "World"
|
||||||
|
};
|
||||||
|
const TS_RATINGS = {
|
||||||
|
370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance",
|
||||||
|
374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost",
|
||||||
|
379: "Vitality"
|
||||||
|
};
|
||||||
|
const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" };
|
||||||
|
const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" };
|
||||||
|
const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" };
|
||||||
|
const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" };
|
||||||
|
|
||||||
|
function _tsSocietyRank(v) {
|
||||||
|
if (v >= 1001) return "Master";
|
||||||
|
if (v >= 301) return "Lord";
|
||||||
|
if (v >= 151) return "Knight";
|
||||||
|
if (v >= 31) return "Adept";
|
||||||
|
return "Initiate";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tsSetupTabs(container) {
|
||||||
|
const tabs = container.querySelectorAll('.ts-tab');
|
||||||
|
const boxes = container.querySelectorAll('.ts-box');
|
||||||
|
tabs.forEach((tab, i) => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); });
|
||||||
|
boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); });
|
||||||
|
tab.classList.remove('inactive'); tab.classList.add('active');
|
||||||
|
if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showCharacterWindow(name) {
|
function showCharacterWindow(name) {
|
||||||
debugLog('showCharacterWindow called for:', name);
|
debugLog('showCharacterWindow called for:', name);
|
||||||
const windowId = `characterWindow-${name}`;
|
const windowId = `characterWindow-${name}`;
|
||||||
|
|
@ -1106,71 +1163,98 @@ function showCharacterWindow(name) {
|
||||||
|
|
||||||
const esc = CSS.escape(name);
|
const esc = CSS.escape(name);
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="ac-panel">
|
<div class="ts-character-header" id="charHeader-${esc}">
|
||||||
<div class="ac-header" id="charHeader-${esc}">
|
<h1>${name} <span class="ts-level"></span></h1>
|
||||||
<div class="ac-name">${name}</div>
|
<div class="ts-subtitle">Awaiting character data...</div>
|
||||||
<div class="ac-subtitle">Awaiting character data...</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-section">
|
<div class="ts-xplum" id="charXpLum-${esc}">
|
||||||
<div class="ac-section-title">Attributes</div>
|
<div class="ts-left">Total XP: \u2014</div>
|
||||||
<div class="ac-attributes" id="charAttribs-${esc}">
|
<div class="ts-right">Unassigned XP: \u2014</div>
|
||||||
<div class="ac-attr-row">
|
<div class="ts-left">Luminance: \u2014</div>
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Strength</span><span class="ac-attr-value">\u2014</span></div>
|
<div class="ts-right">Deaths: \u2014</div>
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Quickness</span><span class="ac-attr-value">\u2014</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-attr-row">
|
<div class="ts-tabrow">
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Endurance</span><span class="ac-attr-value">\u2014</span></div>
|
<div class="ts-tabcontainer" id="charTabLeft-${esc}">
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Focus</span><span class="ac-attr-value">\u2014</span></div>
|
<div class="ts-tabbar">
|
||||||
|
<div class="ts-tab active">Attributes</div>
|
||||||
|
<div class="ts-tab inactive">Skills</div>
|
||||||
|
<div class="ts-tab inactive">Titles</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-attr-row">
|
<div class="ts-box active" id="charAttribs-${esc}">
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Coordination</span><span class="ac-attr-value">\u2014</span></div>
|
<div class="ts-vitals" id="charVitals-${esc}">
|
||||||
<div class="ac-attr"><span class="ac-attr-label">Self</span><span class="ac-attr-value">\u2014</span></div>
|
<div class="ts-vital">
|
||||||
|
<span class="ts-vital-label">Health</span>
|
||||||
|
<div class="ts-vital-bar ts-health-bar"><div class="ts-vital-fill"></div></div>
|
||||||
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
||||||
|
</div>
|
||||||
|
<div class="ts-vital">
|
||||||
|
<span class="ts-vital-label">Stamina</span>
|
||||||
|
<div class="ts-vital-bar ts-stamina-bar"><div class="ts-vital-fill"></div></div>
|
||||||
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
||||||
|
</div>
|
||||||
|
<div class="ts-vital">
|
||||||
|
<span class="ts-vital-label">Mana</span>
|
||||||
|
<div class="ts-vital-bar ts-mana-bar"><div class="ts-vital-fill"></div></div>
|
||||||
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="ts-char" id="charAttribTable-${esc}">
|
||||||
|
<tr class="ts-colnames"><td>Attribute</td><td>Creation</td><td>Base</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Strength</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Endurance</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Coordination</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Quickness</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Focus</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Self</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
</table>
|
||||||
|
<table class="ts-char" id="charVitalsTable-${esc}">
|
||||||
|
<tr class="ts-colnames"><td>Vital</td><td>Base</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Health</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Stamina</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
<tr><td class="ts-headerleft">Mana</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
</table>
|
||||||
|
<table class="ts-char" id="charCredits-${esc}">
|
||||||
|
<tr><td class="ts-headerleft">Skill Credits</td><td class="ts-headerright">\u2014</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="ts-box inactive" id="charSkills-${esc}">
|
||||||
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
|
</div>
|
||||||
|
<div class="ts-box inactive" id="charTitles-${esc}">
|
||||||
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ts-tabcontainer" id="charTabRight-${esc}">
|
||||||
|
<div class="ts-tabbar">
|
||||||
|
<div class="ts-tab active">Augmentations</div>
|
||||||
|
<div class="ts-tab inactive">Ratings</div>
|
||||||
|
<div class="ts-tab inactive">Other</div>
|
||||||
|
</div>
|
||||||
|
<div class="ts-box active" id="charAugs-${esc}">
|
||||||
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
|
</div>
|
||||||
|
<div class="ts-box inactive" id="charRatings-${esc}">
|
||||||
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
|
</div>
|
||||||
|
<div class="ts-box inactive" id="charOther-${esc}">
|
||||||
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-section">
|
<div class="ts-allegiance-section" id="charAllegiance-${esc}">
|
||||||
<div class="ac-section-title">Vitals</div>
|
<div class="ts-section-title">Allegiance</div>
|
||||||
<div class="ac-vitals" id="charVitals-${esc}">
|
<div class="ts-placeholder">Awaiting data...</div>
|
||||||
<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">\u2014 / \u2014</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">\u2014 / \u2014</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">\u2014 / \u2014</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ac-section ac-skills-section">
|
|
||||||
<div class="ac-section-title">Skills</div>
|
|
||||||
<div class="ac-skills" id="charSkills-${esc}">
|
|
||||||
<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-${esc}">
|
|
||||||
<div class="ac-skill-placeholder">Awaiting data...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ac-footer" id="charFooter-${esc}">
|
|
||||||
<div class="ac-footer-row"><span>Total XP:</span><span>\u2014</span></div>
|
|
||||||
<div class="ac-footer-row"><span>Unassigned XP:</span><span>\u2014</span></div>
|
|
||||||
<div class="ac-footer-row"><span>Luminance:</span><span>\u2014</span></div>
|
|
||||||
<div class="ac-footer-row"><span>Deaths:</span><span>\u2014</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Wire up tab switching
|
||||||
|
const leftTabs = document.getElementById(`charTabLeft-${esc}`);
|
||||||
|
const rightTabs = document.getElementById(`charTabRight-${esc}`);
|
||||||
|
if (leftTabs) _tsSetupTabs(leftTabs);
|
||||||
|
if (rightTabs) _tsSetupTabs(rightTabs);
|
||||||
|
|
||||||
// Fetch existing data from API
|
// Fetch existing data from API
|
||||||
fetch(`${API_BASE}/api/character-stats/${encodeURIComponent(name)}`)
|
fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data && !data.error) {
|
if (data && !data.error) {
|
||||||
|
|
@ -1187,130 +1271,260 @@ function showCharacterWindow(name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCharacterWindow(name, data) {
|
function updateCharacterWindow(name, data) {
|
||||||
const escapedName = CSS.escape(name);
|
const esc = CSS.escape(name);
|
||||||
|
const fmt = n => n != null ? n.toLocaleString() : '\u2014';
|
||||||
|
|
||||||
// Header
|
// -- Header --
|
||||||
const header = document.getElementById(`charHeader-${escapedName}`);
|
const header = document.getElementById(`charHeader-${esc}`);
|
||||||
if (header) {
|
if (header) {
|
||||||
const level = data.level || '?';
|
const level = data.level || '?';
|
||||||
const race = data.race || '';
|
const race = data.race || '';
|
||||||
const gender = data.gender || '';
|
const gender = data.gender || '';
|
||||||
const subtitle = [`Level ${level}`, race, gender].filter(Boolean).join(' \u00b7 ');
|
const parts = [gender, race].filter(Boolean).join(' ');
|
||||||
header.querySelector('.ac-subtitle').textContent = subtitle;
|
header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...';
|
||||||
|
const levelSpan = header.querySelector('.ts-level');
|
||||||
|
if (levelSpan) levelSpan.textContent = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attributes
|
// -- XP / Luminance row --
|
||||||
const attribs = document.getElementById(`charAttribs-${escapedName}`);
|
const xplum = document.getElementById(`charXpLum-${esc}`);
|
||||||
if (attribs && data.attributes) {
|
if (xplum) {
|
||||||
const order = [
|
const divs = xplum.querySelectorAll('div');
|
||||||
['strength', 'quickness'],
|
if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`;
|
||||||
['endurance', 'focus'],
|
if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`;
|
||||||
['coordination', 'self']
|
if (divs[2]) {
|
||||||
];
|
const lum = data.luminance_earned != null && data.luminance_total != null
|
||||||
const rows = attribs.querySelectorAll('.ac-attr-row');
|
? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}`
|
||||||
order.forEach((pair, i) => {
|
: '\u2014';
|
||||||
if (rows[i]) {
|
divs[2].textContent = `Luminance: ${lum}`;
|
||||||
const cells = rows[i].querySelectorAll('.ac-attr-value');
|
|
||||||
pair.forEach((attr, j) => {
|
|
||||||
if (cells[j] && data.attributes[attr]) {
|
|
||||||
const val = data.attributes[attr].base || '\u2014';
|
|
||||||
const creation = data.attributes[attr].creation;
|
|
||||||
cells[j].textContent = val;
|
|
||||||
if (creation !== undefined) {
|
|
||||||
cells[j].title = `Creation: ${creation}`;
|
|
||||||
}
|
}
|
||||||
|
if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// -- Attributes table --
|
||||||
|
const attribTable = document.getElementById(`charAttribTable-${esc}`);
|
||||||
|
if (attribTable && data.attributes) {
|
||||||
|
const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self'];
|
||||||
|
const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)');
|
||||||
|
order.forEach((attr, i) => {
|
||||||
|
if (rows[i] && data.attributes[attr]) {
|
||||||
|
const cells = rows[i].querySelectorAll('td');
|
||||||
|
if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014';
|
||||||
|
if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills
|
// -- Vitals table (base values) --
|
||||||
const skillsDiv = document.getElementById(`charSkills-${escapedName}`);
|
const vitalsTable = document.getElementById(`charVitalsTable-${esc}`);
|
||||||
if (skillsDiv && data.skills) {
|
if (vitalsTable && data.vitals) {
|
||||||
const grouped = { Specialized: [], Trained: [], Untrained: [] };
|
const vOrder = ['health', 'stamina', 'mana'];
|
||||||
|
const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)');
|
||||||
|
vOrder.forEach((v, i) => {
|
||||||
|
if (vRows[i] && data.vitals[v]) {
|
||||||
|
const cells = vRows[i].querySelectorAll('td');
|
||||||
|
if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Skill credits --
|
||||||
|
const creditsTable = document.getElementById(`charCredits-${esc}`);
|
||||||
|
if (creditsTable) {
|
||||||
|
const cell = creditsTable.querySelector('td.ts-headerright');
|
||||||
|
if (cell) cell.textContent = fmt(data.skill_credits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Skills tab --
|
||||||
|
const skillsBox = document.getElementById(`charSkills-${esc}`);
|
||||||
|
if (skillsBox && data.skills) {
|
||||||
|
const grouped = { Specialized: [], Trained: [] };
|
||||||
for (const [skill, info] of Object.entries(data.skills)) {
|
for (const [skill, info] of Object.entries(data.skills)) {
|
||||||
const training = info.training || 'Untrained';
|
const training = info.training || 'Untrained';
|
||||||
|
if (training === 'Untrained' || training === 'Unusable') continue;
|
||||||
const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
if (grouped[training]) {
|
if (grouped[training]) grouped[training].push({ name: displayName, base: info.base });
|
||||||
grouped[training].push({ name: displayName, base: info.base });
|
}
|
||||||
|
for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
let html = '<table class="ts-char">';
|
||||||
|
html += '<tr class="ts-colnames"><td>Skill</td><td>Level</td></tr>';
|
||||||
|
if (grouped.Specialized.length) {
|
||||||
|
for (const s of grouped.Specialized) {
|
||||||
|
html += `<tr><td class="ts-specialized">${s.name}</td><td class="ts-specialized" style="text-align:right">${s.base}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const group of Object.values(grouped)) {
|
if (grouped.Trained.length) {
|
||||||
group.sort((a, b) => b.base - a.base);
|
for (const s of grouped.Trained) {
|
||||||
|
html += `<tr><td class="ts-trained">${s.name}</td><td class="ts-trained" style="text-align:right">${s.base}</td></tr>`;
|
||||||
}
|
}
|
||||||
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>`;
|
html += '</table>';
|
||||||
}
|
skillsBox.innerHTML = html;
|
||||||
skillsDiv.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allegiance
|
// -- Titles tab --
|
||||||
const allegianceDiv = document.getElementById(`charAllegiance-${escapedName}`);
|
const titlesBox = document.getElementById(`charTitles-${esc}`);
|
||||||
if (allegianceDiv && data.allegiance) {
|
if (titlesBox) {
|
||||||
|
const statsData = data.stats_data || data;
|
||||||
|
const titles = statsData.titles;
|
||||||
|
if (titles && titles.length > 0) {
|
||||||
|
let html = '<div class="ts-titles-list">';
|
||||||
|
for (const t of titles) html += `<div>${t}</div>`;
|
||||||
|
html += '</div>';
|
||||||
|
titlesBox.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
titlesBox.innerHTML = '<div class="ts-placeholder">No titles data</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Properties-based tabs (Augmentations, Ratings, Other) --
|
||||||
|
const statsData = data.stats_data || data;
|
||||||
|
const props = statsData.properties || {};
|
||||||
|
|
||||||
|
// Augmentations tab
|
||||||
|
const augsBox = document.getElementById(`charAugs-${esc}`);
|
||||||
|
if (augsBox) {
|
||||||
|
let augRows = [], auraRows = [];
|
||||||
|
for (const [id, val] of Object.entries(props)) {
|
||||||
|
const nid = parseInt(id);
|
||||||
|
if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val });
|
||||||
|
if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val });
|
||||||
|
}
|
||||||
|
if (augRows.length || auraRows.length) {
|
||||||
|
let html = '';
|
||||||
|
if (augRows.length) {
|
||||||
|
html += '<div class="ts-section-title">Augmentations</div>';
|
||||||
|
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
|
||||||
|
for (const a of augRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
if (auraRows.length) {
|
||||||
|
html += '<div class="ts-section-title">Auras</div>';
|
||||||
|
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
|
||||||
|
for (const a of auraRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
augsBox.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
augsBox.innerHTML = '<div class="ts-placeholder">No augmentation data</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratings tab
|
||||||
|
const ratingsBox = document.getElementById(`charRatings-${esc}`);
|
||||||
|
if (ratingsBox) {
|
||||||
|
let rows = [];
|
||||||
|
for (const [id, val] of Object.entries(props)) {
|
||||||
|
const nid = parseInt(id);
|
||||||
|
if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val });
|
||||||
|
}
|
||||||
|
if (rows.length) {
|
||||||
|
let html = '<table class="ts-props"><tr class="ts-colnames"><td>Rating</td><td>Value</td></tr>';
|
||||||
|
for (const r of rows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
ratingsBox.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
ratingsBox.innerHTML = '<div class="ts-placeholder">No rating data</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other tab (General, Masteries, Society)
|
||||||
|
const otherBox = document.getElementById(`charOther-${esc}`);
|
||||||
|
if (otherBox) {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// General section
|
||||||
|
let generalRows = [];
|
||||||
|
if (data.birth) generalRows.push({ name: 'Birth', value: data.birth });
|
||||||
|
if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
|
||||||
|
for (const [id, val] of Object.entries(props)) {
|
||||||
|
const nid = parseInt(id);
|
||||||
|
if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val });
|
||||||
|
}
|
||||||
|
if (generalRows.length) {
|
||||||
|
html += '<div class="ts-section-title">General</div>';
|
||||||
|
html += '<table class="ts-props">';
|
||||||
|
for (const r of generalRows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masteries section
|
||||||
|
let masteryRows = [];
|
||||||
|
for (const [id, val] of Object.entries(props)) {
|
||||||
|
const nid = parseInt(id);
|
||||||
|
if (TS_MASTERIES[nid]) {
|
||||||
|
const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`;
|
||||||
|
masteryRows.push({ name: TS_MASTERIES[nid], value: mName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (masteryRows.length) {
|
||||||
|
html += '<div class="ts-section-title">Masteries</div>';
|
||||||
|
html += '<table class="ts-props">';
|
||||||
|
for (const m of masteryRows) html += `<tr><td>${m.name}</td><td style="text-align:right">${m.value}</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Society section
|
||||||
|
let societyRows = [];
|
||||||
|
for (const [id, val] of Object.entries(props)) {
|
||||||
|
const nid = parseInt(id);
|
||||||
|
if (TS_SOCIETY[nid] && val > 0) {
|
||||||
|
societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (societyRows.length) {
|
||||||
|
html += '<div class="ts-section-title">Society</div>';
|
||||||
|
html += '<table class="ts-props">';
|
||||||
|
for (const s of societyRows) html += `<tr><td>${s.name}</td><td style="text-align:right">${s.rank} (${s.value})</td></tr>`;
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
otherBox.innerHTML = html || '<div class="ts-placeholder">No additional data</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Allegiance section --
|
||||||
|
const allegDiv = document.getElementById(`charAllegiance-${esc}`);
|
||||||
|
if (allegDiv && data.allegiance) {
|
||||||
const a = data.allegiance;
|
const a = data.allegiance;
|
||||||
let html = '';
|
let html = '<div class="ts-section-title">Allegiance</div>';
|
||||||
if (a.name) html += `<div class="ac-alleg-row"><span>Allegiance:</span><span>${a.name}</span></div>`;
|
html += '<table class="ts-allegiance">';
|
||||||
if (a.monarch) html += `<div class="ac-alleg-row"><span>Monarch:</span><span>${a.monarch.name || '\u2014'}</span></div>`;
|
if (a.name) html += `<tr><td>Name</td><td>${a.name}</td></tr>`;
|
||||||
if (a.patron) html += `<div class="ac-alleg-row"><span>Patron:</span><span>${a.patron.name || '\u2014'}</span></div>`;
|
if (a.monarch) html += `<tr><td>Monarch</td><td>${a.monarch.name || '\u2014'}</td></tr>`;
|
||||||
if (a.rank !== undefined) html += `<div class="ac-alleg-row"><span>Rank:</span><span>${a.rank}</span></div>`;
|
if (a.patron) html += `<tr><td>Patron</td><td>${a.patron.name || '\u2014'}</td></tr>`;
|
||||||
if (a.followers !== undefined) html += `<div class="ac-alleg-row"><span>Followers:</span><span>${a.followers}</span></div>`;
|
if (a.rank !== undefined) html += `<tr><td>Rank</td><td>${a.rank}</td></tr>`;
|
||||||
allegianceDiv.innerHTML = html || '<div class="ac-skill-placeholder">No allegiance</div>';
|
if (a.followers !== undefined) html += `<tr><td>Followers</td><td>${a.followers}</td></tr>`;
|
||||||
}
|
html += '</table>';
|
||||||
|
allegDiv.innerHTML = html;
|
||||||
// Footer
|
|
||||||
const footer = document.getElementById(`charFooter-${escapedName}`);
|
|
||||||
if (footer) {
|
|
||||||
const rows = footer.querySelectorAll('.ac-footer-row');
|
|
||||||
const formatNum = n => n != null ? n.toLocaleString() : '\u2014';
|
|
||||||
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)}`
|
|
||||||
: '\u2014';
|
|
||||||
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) {
|
function updateCharacterVitals(name, vitals) {
|
||||||
const escapedName = CSS.escape(name);
|
const esc = CSS.escape(name);
|
||||||
const vitalsDiv = document.getElementById(`charVitals-${escapedName}`);
|
const vitalsDiv = document.getElementById(`charVitals-${esc}`);
|
||||||
if (!vitalsDiv) return;
|
if (!vitalsDiv) return;
|
||||||
|
|
||||||
const vitalElements = vitalsDiv.querySelectorAll('.ac-vital');
|
const vitalElements = vitalsDiv.querySelectorAll('.ts-vital');
|
||||||
|
|
||||||
if (vitalElements[0]) {
|
if (vitalElements[0]) {
|
||||||
const fill = vitalElements[0].querySelector('.ac-vital-fill');
|
const fill = vitalElements[0].querySelector('.ts-vital-fill');
|
||||||
const txt = vitalElements[0].querySelector('.ac-vital-text');
|
const txt = vitalElements[0].querySelector('.ts-vital-text');
|
||||||
if (fill) fill.style.width = `${vitals.health_percentage || 0}%`;
|
if (fill) fill.style.width = `${vitals.health_percentage || 0}%`;
|
||||||
if (txt && vitals.health_current !== undefined) {
|
if (txt && vitals.health_current !== undefined) {
|
||||||
txt.textContent = `${vitals.health_current} / ${vitals.health_max}`;
|
txt.textContent = `${vitals.health_current} / ${vitals.health_max}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (vitalElements[1]) {
|
if (vitalElements[1]) {
|
||||||
const fill = vitalElements[1].querySelector('.ac-vital-fill');
|
const fill = vitalElements[1].querySelector('.ts-vital-fill');
|
||||||
const txt = vitalElements[1].querySelector('.ac-vital-text');
|
const txt = vitalElements[1].querySelector('.ts-vital-text');
|
||||||
if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`;
|
if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`;
|
||||||
if (txt && vitals.stamina_current !== undefined) {
|
if (txt && vitals.stamina_current !== undefined) {
|
||||||
txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`;
|
txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (vitalElements[2]) {
|
if (vitalElements[2]) {
|
||||||
const fill = vitalElements[2].querySelector('.ac-vital-fill');
|
const fill = vitalElements[2].querySelector('.ts-vital-fill');
|
||||||
const txt = vitalElements[2].querySelector('.ac-vital-text');
|
const txt = vitalElements[2].querySelector('.ts-vital-text');
|
||||||
if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`;
|
if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`;
|
||||||
if (txt && vitals.mana_current !== undefined) {
|
if (txt && vitals.mana_current !== undefined) {
|
||||||
txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`;
|
txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`;
|
||||||
|
|
|
||||||
336
static/style.css
336
static/style.css
|
|
@ -1593,202 +1593,258 @@ body.noselect, body.noselect * {
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Character Window - AC Game UI Replica
|
Character Window - AC Game UI Replica
|
||||||
============================================ */
|
============================================ */
|
||||||
|
/* === TreeStats-themed Character Window === */
|
||||||
.character-window {
|
.character-window {
|
||||||
width: 450px !important;
|
width: 740px !important;
|
||||||
height: 650px !important;
|
height: auto !important;
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
.character-window .window-content {
|
||||||
|
background-color: #000022;
|
||||||
|
color: #fff;
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 15px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-panel {
|
/* -- Character header (name, level, title, server, XP/Lum) -- */
|
||||||
|
.ts-character-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ts-character-header h1 {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.ts-character-header h1 span.ts-level {
|
||||||
|
font-size: 200%;
|
||||||
|
color: #fff27f;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.ts-character-header .ts-subtitle {
|
||||||
|
font-size: 85%;
|
||||||
|
color: gold;
|
||||||
|
}
|
||||||
|
.ts-xplum {
|
||||||
|
font-size: 85%;
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 20px;
|
||||||
|
}
|
||||||
|
.ts-xplum .ts-left { text-align: left; }
|
||||||
|
.ts-xplum .ts-right { text-align: right; }
|
||||||
|
|
||||||
|
/* -- Tab containers (two side-by-side) -- */
|
||||||
|
.ts-tabrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 20px;
|
||||||
height: 100%;
|
flex-wrap: wrap;
|
||||||
background: linear-gradient(135deg, #1a1410 0%, #2a2218 50%, #1a1410 100%);
|
}
|
||||||
color: #c8b89a;
|
.ts-tabcontainer {
|
||||||
font-size: 13px;
|
width: 320px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.ts-tabbar {
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.ts-tab {
|
||||||
|
float: left;
|
||||||
|
display: block;
|
||||||
|
padding: 5px 5px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ts-tab.active {
|
||||||
|
border-top: 2px solid #af7a30;
|
||||||
|
border-right: 2px solid #af7a30;
|
||||||
|
border-left: 2px solid #af7a30;
|
||||||
|
border-bottom: none;
|
||||||
|
background-color: rgba(0, 100, 0, 0.4);
|
||||||
|
}
|
||||||
|
.ts-tab.inactive {
|
||||||
|
border-top: 2px solid #000022;
|
||||||
|
border-right: 2px solid #000022;
|
||||||
|
border-left: 2px solid #000022;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.ts-box {
|
||||||
|
background-color: black;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #af7a30;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.ts-box.active { display: block; }
|
||||||
|
.ts-box.inactive { display: none; }
|
||||||
|
|
||||||
.ac-header {
|
/* -- Tables inside boxes -- */
|
||||||
padding: 12px 15px;
|
table.ts-char {
|
||||||
border-bottom: 1px solid #8b7355;
|
width: 100%;
|
||||||
text-align: center;
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
}
|
}
|
||||||
.ac-name {
|
table.ts-char td {
|
||||||
font-size: 18px;
|
padding: 2px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.ts-char tr.ts-colnames td {
|
||||||
|
background-color: #222;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #d4a843;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
.ac-subtitle {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #7a6e5e;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-section {
|
/* Attribute cells */
|
||||||
padding: 8px 15px;
|
table.ts-char td.ts-headerleft {
|
||||||
border-bottom: 1px solid #3a3228;
|
background-color: rgba(0, 100, 0, 0.4);
|
||||||
}
|
}
|
||||||
.ac-section-title {
|
table.ts-char td.ts-headerright {
|
||||||
font-size: 11px;
|
background-color: rgba(0, 0, 100, 0.4);
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 2px;
|
table.ts-char td.ts-creation {
|
||||||
color: #8b7355;
|
color: #ccc;
|
||||||
margin-bottom: 6px;
|
|
||||||
padding-bottom: 3px;
|
|
||||||
border-bottom: 1px solid #3a3228;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-attributes {
|
/* Skill rows */
|
||||||
display: flex;
|
table.ts-char td.ts-specialized {
|
||||||
flex-direction: column;
|
background: linear-gradient(to right, #392067, #392067, black);
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
.ac-attr-row {
|
table.ts-char td.ts-trained {
|
||||||
display: flex;
|
background: linear-gradient(to right, #0f3c3e, #0f3c3e, black);
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
.ac-attr {
|
|
||||||
flex: 1;
|
/* Section headers inside boxes */
|
||||||
display: flex;
|
.ts-box .ts-section-title {
|
||||||
justify-content: space-between;
|
background-color: #222;
|
||||||
padding: 3px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(42, 34, 24, 0.6);
|
font-weight: bold;
|
||||||
border: 1px solid #3a3228;
|
font-size: 13px;
|
||||||
border-radius: 2px;
|
border-bottom: 1px solid #af7a30;
|
||||||
}
|
}
|
||||||
.ac-attr-label {
|
|
||||||
color: #7a6e5e;
|
/* Titles list */
|
||||||
|
.ts-titles-list {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.ac-attr-value {
|
.ts-titles-list div {
|
||||||
color: #d4a843;
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Properties (augmentations, ratings, other) */
|
||||||
|
table.ts-props {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.ts-props td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
table.ts-props tr.ts-colnames td {
|
||||||
|
background-color: #222;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-vitals {
|
/* -- Live vitals bars (inside Attributes tab) -- */
|
||||||
|
.ts-vitals {
|
||||||
|
padding: 6px 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
border-bottom: 2px solid #af7a30;
|
||||||
}
|
}
|
||||||
.ac-vital {
|
.ts-vital {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.ac-vital-label {
|
.ts-vital-label {
|
||||||
width: 55px;
|
width: 55px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #7a6e5e;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
.ac-vital-bar {
|
.ts-vital-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: 1px solid #af7a30;
|
||||||
}
|
}
|
||||||
.ac-vital-fill {
|
.ts-vital-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: width 0.5s ease;
|
transition: width 0.5s ease;
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
.ac-health-bar { background: #4a1a1a; }
|
.ts-health-bar .ts-vital-fill { background: #cc3333; width: 0%; }
|
||||||
.ac-health-bar .ac-vital-fill { background: #cc3333; width: 0%; }
|
.ts-stamina-bar .ts-vital-fill { background: #ccaa33; width: 0%; }
|
||||||
.ac-stamina-bar { background: #4a3a1a; }
|
.ts-mana-bar .ts-vital-fill { background: #3366cc; width: 0%; }
|
||||||
.ac-stamina-bar .ac-vital-fill { background: #ccaa33; width: 0%; }
|
.ts-vital-text {
|
||||||
.ac-mana-bar { background: #1a2a4a; }
|
|
||||||
.ac-mana-bar .ac-vital-fill { background: #3366cc; width: 0%; }
|
|
||||||
.ac-vital-text {
|
|
||||||
width: 80px;
|
width: 80px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c8b89a;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-skills-section {
|
/* -- Allegiance section (below tabs) -- */
|
||||||
flex: 1;
|
.ts-allegiance-section {
|
||||||
min-height: 0;
|
margin-top: 5px;
|
||||||
display: flex;
|
border: 2px solid #af7a30;
|
||||||
flex-direction: column;
|
background-color: black;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.ac-skills {
|
.ts-allegiance-section .ts-section-title {
|
||||||
overflow-y: auto;
|
background-color: #222;
|
||||||
max-height: 200px;
|
padding: 4px 8px;
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.ac-skill-group {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.ac-skill-group-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
font-size: 13px;
|
||||||
letter-spacing: 1px;
|
border-bottom: 1px solid #af7a30;
|
||||||
padding: 3px 8px;
|
}
|
||||||
margin-bottom: 1px;
|
table.ts-allegiance {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.ts-allegiance td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
table.ts-allegiance td:first-child {
|
||||||
|
color: #ccc;
|
||||||
|
width: 100px;
|
||||||
}
|
}
|
||||||
.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 {
|
/* Awaiting data placeholder */
|
||||||
display: flex;
|
.ts-placeholder {
|
||||||
justify-content: space-between;
|
color: #666;
|
||||||
padding: 2px 12px;
|
font-style: italic;
|
||||||
border-bottom: 1px solid rgba(58, 50, 40, 0.4);
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.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; }
|
/* Scrollbar styling for ts-box */
|
||||||
.ac-skill-value { font-size: 12px; font-family: monospace; }
|
.ts-box::-webkit-scrollbar { width: 8px; }
|
||||||
.ac-skill-placeholder { color: #5a5248; font-style: italic; padding: 8px; }
|
.ts-box::-webkit-scrollbar-track { background: #000; }
|
||||||
|
.ts-box::-webkit-scrollbar-thumb { background: #af7a30; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.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-btn {
|
.char-btn {
|
||||||
background: #2a2218;
|
background: #000022;
|
||||||
color: #d4a843;
|
color: #af7a30;
|
||||||
border: 1px solid #8b7355;
|
border: 1px solid #af7a30;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.char-btn:hover {
|
.char-btn:hover {
|
||||||
background: #3a3228;
|
background: rgba(0, 100, 0, 0.4);
|
||||||
border-color: #d4a843;
|
border-color: #af7a30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue